mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
merge main 07094d9 into feat/voice-outlines
This commit is contained in:
commit
c5aa0d28c3
593 files changed
+42386
-19629
No files matched your search
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// src/ui/awarenessPanel.test.mjs — the Contacts panel renders what the
|
||||
// Contacts layer publishes (`subscribePanel()`), over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { useContextStore } from './stores/context.js';
|
||||
import AwarenessPanel from './views/panels/AwarenessPanel.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(AwarenessPanel).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useContextStore(pinia);
|
||||
const calls = [];
|
||||
store.connectAwareness({
|
||||
navigatePrevious: (options) => calls.push(['previous', options]),
|
||||
focusCurrent: (options) => calls.push(['focus', options]),
|
||||
navigateNext: (options) => calls.push(['next', options]),
|
||||
focusTarget: (layer, id, options) =>
|
||||
calls.push([`target:${layer}:${id}`, options]),
|
||||
});
|
||||
const panel = () => document.getElementById('military-awareness-panel');
|
||||
const publish = async (view) => {
|
||||
store.publishAwareness(view);
|
||||
await nextTick();
|
||||
};
|
||||
return { panel, publish, calls };
|
||||
}
|
||||
|
||||
const target = (layer, id, label, extra = {}) => ({
|
||||
key: `target:${layer}:${id}`,
|
||||
layer,
|
||||
targetId: id,
|
||||
label,
|
||||
distance: '4.6 km',
|
||||
ariaLabel: `Focus ${label}`,
|
||||
names: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const results = ({ nearest = [], previousDisabled = true } = {}) => ({
|
||||
kind: 'results',
|
||||
subject: 'N123AB · 250 km FLIGHT / VESSEL WINDOW',
|
||||
subjectNames: null,
|
||||
navigation: {
|
||||
label: 'Global Context navigation',
|
||||
previous: {
|
||||
text: 'PREVIOUS',
|
||||
title: 'Previous',
|
||||
disabled: previousDisabled,
|
||||
},
|
||||
focus: { text: 'FOCUS' },
|
||||
next: { text: 'NEXT', title: 'Next', disabled: false },
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
id: 'flights',
|
||||
relationship: 'observed',
|
||||
label: 'Flights',
|
||||
count: '2',
|
||||
detail: 'ADS-B · CURRENT VIEWPORT ONLY · nearest',
|
||||
nearest,
|
||||
},
|
||||
],
|
||||
note: 'Open-source mapped/observed context.',
|
||||
});
|
||||
|
||||
test('before the layer publishes, the panel shows its initial markup', (t) => {
|
||||
const { panel } = fixture(t);
|
||||
const standby = panel().querySelector('.military-awareness-standby');
|
||||
assert.equal(
|
||||
standby.querySelector('strong').textContent,
|
||||
'CONTACTS CONTEXT OFF',
|
||||
);
|
||||
// Rendered through t(), so a later null view is translated too.
|
||||
assert.equal(
|
||||
standby.querySelector('strong').hasAttribute('data-i18n'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a standby view replaces the initial copy', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
await publish({
|
||||
kind: 'standby',
|
||||
title: 'CONTEXT READY',
|
||||
text: 'SELECT A FLIGHT',
|
||||
});
|
||||
const standby = panel().querySelector('.military-awareness-standby');
|
||||
assert.equal(standby.querySelector('strong').textContent, 'CONTEXT READY');
|
||||
assert.equal(standby.querySelector('span').textContent, 'SELECT A FLIGHT');
|
||||
await publish({ kind: 'empty' });
|
||||
assert.equal(panel().children.length, 0);
|
||||
});
|
||||
|
||||
test('results render the rows, controls and names as text, and buttons call the layer', async (t) => {
|
||||
const { panel, publish, calls } = fixture(t);
|
||||
const view = results({
|
||||
nearest: [
|
||||
target('flights', 'abc123', 'ALPHA', {
|
||||
names: {
|
||||
summary: 'Named areas (2)',
|
||||
names: ['Area one', '<Area two>'],
|
||||
},
|
||||
}),
|
||||
{
|
||||
key: 'unavailable:1',
|
||||
label: '—',
|
||||
distance: '9 km',
|
||||
ariaLabel: 'Unavailable',
|
||||
},
|
||||
],
|
||||
});
|
||||
await publish(view);
|
||||
const root = panel();
|
||||
assert.equal(
|
||||
root.querySelector('.military-awareness-subject').textContent,
|
||||
view.subject,
|
||||
);
|
||||
const row = root.querySelector('.military-awareness-row');
|
||||
assert.equal(row.className, 'military-awareness-row observed');
|
||||
assert.equal(row.querySelector('b').getAttribute('aria-live'), 'polite');
|
||||
assert.equal(row.querySelector('b').textContent, '2');
|
||||
assert.equal(row.querySelector('small').textContent, view.rows[0].detail);
|
||||
const alpha = row.querySelector('button[data-awareness-id="abc123"]');
|
||||
assert.equal(alpha.dataset.awarenessLayer, 'flights');
|
||||
assert.equal(alpha.getAttribute('aria-label'), 'Focus ALPHA');
|
||||
assert.equal(alpha.textContent, 'ALPHA 4.6 km');
|
||||
const names = row.querySelector('.military-awareness-names div');
|
||||
assert.equal(
|
||||
names.innerHTML.replace(/<!--[^>]*-->/g, ''),
|
||||
'Area one<br><Area two>',
|
||||
);
|
||||
const unavailable = row.querySelector(
|
||||
'span.military-awareness-target.unavailable',
|
||||
);
|
||||
assert.equal(unavailable.getAttribute('aria-label'), 'Unavailable');
|
||||
|
||||
const [previous, focus, next] = root.querySelectorAll(
|
||||
'[data-awareness-action]',
|
||||
);
|
||||
assert.equal(previous.disabled, true);
|
||||
assert.equal(next.title, 'Next');
|
||||
focus.click();
|
||||
next.click();
|
||||
alpha.click();
|
||||
const user = { origin: 'user' };
|
||||
assert.deepEqual(calls, [
|
||||
['focus', user],
|
||||
['next', user],
|
||||
['target:flights:abc123', user],
|
||||
]);
|
||||
const note = root.querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(note.tagName, 'P');
|
||||
assert.equal(note.getAttribute('tabindex'), '-1');
|
||||
});
|
||||
|
||||
test('a live repaint keeps a focused contact by identity and never retargets another', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const alpha = target('flights', 'abc123', 'ALPHA');
|
||||
const bravo = target('flights', 'def456', 'BRAVO');
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
const alphaButton = panel().querySelector('[data-awareness-id="abc123"]');
|
||||
alphaButton.focus();
|
||||
|
||||
await publish(results({ nearest: [bravo, alpha] }));
|
||||
assert.equal(
|
||||
document.activeElement === alphaButton,
|
||||
true,
|
||||
'identity wins when rows reorder',
|
||||
);
|
||||
|
||||
const charlie = target('ais-live-vessels', '789', 'CHARLIE');
|
||||
await publish(results({ nearest: [charlie] }));
|
||||
const note = panel().querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(
|
||||
document.activeElement === note,
|
||||
true,
|
||||
'a departed contact continues at the note',
|
||||
);
|
||||
|
||||
await publish(results({ nearest: [bravo] }));
|
||||
assert.equal(
|
||||
document.activeElement === note,
|
||||
true,
|
||||
'later repaints keep the continuation point',
|
||||
);
|
||||
});
|
||||
|
||||
test('a reorder that drops focus from a moved contact restores the same contact', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const alpha = target('flights', 'abc123', 'ALPHA');
|
||||
const bravo = target('flights', 'def456', 'BRAVO');
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
// Browsers drop focus from a node that is moved; happy-dom does not, so
|
||||
// moving the focused node sends focus outside the panel here.
|
||||
const sink = document.body.appendChild(document.createElement('button'));
|
||||
const list = panel().querySelector('ul');
|
||||
const insertBefore = list.insertBefore.bind(list);
|
||||
list.insertBefore = (node, ref) => {
|
||||
const moved = node.contains(document.activeElement);
|
||||
const result = insertBefore(node, ref);
|
||||
if (moved) sink.focus();
|
||||
return result;
|
||||
};
|
||||
const focusNode = (id) =>
|
||||
panel().querySelector(`[data-awareness-id="${id}"]`);
|
||||
focusNode('def456').focus();
|
||||
await publish(results({ nearest: [bravo, alpha] }));
|
||||
focusNode('abc123').focus();
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
assert.equal(
|
||||
document.activeElement === focusNode('abc123'),
|
||||
true,
|
||||
'the same contact keeps focus, not another one or the note',
|
||||
);
|
||||
});
|
||||
|
||||
test('a repaint that disables the focused control continues at the note', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
await publish(results({ previousDisabled: false }));
|
||||
const previous = panel().querySelector('[data-awareness-action="previous"]');
|
||||
previous.focus();
|
||||
await publish(results({ previousDisabled: true }));
|
||||
assert.equal(previous.disabled, true);
|
||||
const note = panel().querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(document.activeElement === note, true);
|
||||
});
|
||||
|
||||
test('a repaint that leaves focus outside the panel does not pull it back', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const outside = document.body.appendChild(document.createElement('button'));
|
||||
await publish(results({ nearest: [target('flights', 'abc123', 'ALPHA')] }));
|
||||
outside.focus();
|
||||
await publish(results({ nearest: [] }));
|
||||
assert.equal(document.activeElement === outside, true);
|
||||
assert.equal(
|
||||
panel().querySelector('ul'),
|
||||
null,
|
||||
'an empty cohort renders no list',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useCctvStore } from '../stores/cctv.js';
|
||||
|
||||
// One click-to-type pose chip. The panel's delegated click starts an edit
|
||||
// in CctvControls, which publishes it; the chip then holds a number input
|
||||
// that ends the edit on Enter, blur or Escape.
|
||||
const props = defineProps({
|
||||
field: { type: String, required: true },
|
||||
/** Message for the label shown before the first render. */
|
||||
label: { type: String, required: true },
|
||||
});
|
||||
const { view, calEdit } = storeToRefs(useCctvStore());
|
||||
const edit = computed(() =>
|
||||
calEdit.value?.field === props.field ? calEdit.value : null,
|
||||
);
|
||||
|
||||
// The draft is the user's once the input exists: the starting value is set
|
||||
// on mount only, since re-renders would otherwise write it back.
|
||||
const vDraft = {
|
||||
mounted(el, { value }) {
|
||||
el.value = value;
|
||||
el.focus();
|
||||
el.select();
|
||||
},
|
||||
};
|
||||
|
||||
function onKeydown(event) {
|
||||
if (event.key === 'Enter') edit.value?.finish(true, event.target.value);
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
edit.value?.finish(false);
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onBlur(event) {
|
||||
edit.value?.finish(true, event.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="cctv-cal-value"
|
||||
:data-cal-field="field"
|
||||
:disabled="edit ? false : view?.calibration.disabled"
|
||||
><template v-if="edit"
|
||||
>{{ edit.label
|
||||
}}<input
|
||||
:key="edit.id"
|
||||
v-draft="edit.value"
|
||||
type="number"
|
||||
class="gev-field gev-field--inline cctv-cal-input"
|
||||
:step="edit.step"
|
||||
:aria-label="edit.ariaLabel"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
@click.stop /></template
|
||||
><template v-else>{{
|
||||
view?.calibration.values[field] ?? `${t(label)} --`
|
||||
}}</template></button
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import UiSelect from '../kit/UiSelect.js';
|
||||
import { useCctvStore } from '../stores/cctv.js';
|
||||
|
||||
// The camera picker: the cameras in view and a closing note for those the
|
||||
// view leaves out. It renders only when CctvControls publishes a picker,
|
||||
// which re-applies the active camera, as on every layer notification.
|
||||
const { picker } = storeToRefs(useCctvStore());
|
||||
|
||||
// The selection, applied once the options are in place.
|
||||
const pick = (el, { value: selection }) => {
|
||||
if (!selection) return;
|
||||
if ('value' in selection) {
|
||||
if (el.value !== selection.value) el.value = selection.value;
|
||||
} else if (el.selectedIndex !== selection.index)
|
||||
el.selectedIndex = selection.index;
|
||||
};
|
||||
const vPick = { mounted: pick, updated: pick };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSelect v-pick="picker?.selection" :disabled="picker?.disabled"
|
||||
><option
|
||||
v-for="option in picker?.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>{{ option.label }}</option
|
||||
><option v-if="picker?.note" value="" disabled data-cctv-outside>{{
|
||||
picker.note
|
||||
}}</option></UiSelect
|
||||
>
|
||||
</template>
|
||||
+45
-74
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// src/ui/cockpitContext.test.mjs — the Cockpit Contact card renders what the
|
||||
// Cockpit controller presents, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { setLocale, t as translate } from '../i18n/index.js';
|
||||
import {
|
||||
publishContextView,
|
||||
showContextCard,
|
||||
updateContext,
|
||||
} from './cockpitContext.js';
|
||||
import { createContextState } from './cockpitContextState.js';
|
||||
import { syncWeatherToggle } from './cockpitInstruments.js';
|
||||
import { setContextCollapsed } from './cockpitLayout.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import CockpitContext from './views/panels/CockpitContext.vue';
|
||||
|
||||
function fixture(t, snapshot = null) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitContext).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useCockpitStore(pinia);
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const signals = [];
|
||||
const layouts = [];
|
||||
// The Contact presenter needs only this much of the controller.
|
||||
const owner = {
|
||||
context: $('#cockpit-context'),
|
||||
presence: { context: false },
|
||||
contextView: createContextState(),
|
||||
contextLayoutStamp: null,
|
||||
publishContext: (view) => store.publishContext(view),
|
||||
publishContextView,
|
||||
publishPresenceView: () => store.publishPresence({ ...owner.presence }),
|
||||
showContextCard,
|
||||
pushCockpitSignal: (...args) => signals.push(args),
|
||||
updateCockpitSignals() {},
|
||||
scheduleContextLayout: () => layouts.push(true),
|
||||
services: {
|
||||
militaryAwarenessLayer: { getContextSnapshot: () => owner.snapshot },
|
||||
formatAwarenessLabel: (item) => item?.id ?? '—',
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
const text = (selector) => $(selector).textContent.trim();
|
||||
return { $, text, owner, signals, layouts };
|
||||
}
|
||||
|
||||
const INFO = {
|
||||
layerId: 'flights',
|
||||
icao24: 'abc123',
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
};
|
||||
|
||||
const snapshot = (overrides = {}) => ({
|
||||
subject: { layerId: 'military', id: 'm1', label: 'RCH123' },
|
||||
radiusM: 250_000,
|
||||
evaluatedAt: Date.UTC(2026, 9, 31, 12, 34, 56),
|
||||
navigation: { canPrevious: false, canNext: true },
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
label: 'Flights',
|
||||
count: 12,
|
||||
relationship: 'OBSERVED',
|
||||
nearest: [{ id: 'N1', distanceM: 4200 }],
|
||||
},
|
||||
{
|
||||
id: 'military',
|
||||
label: 'Military',
|
||||
count: null,
|
||||
relationship: 'UNKNOWN',
|
||||
nearest: [],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('before the controller presents, the card shows the state it starts in', (t) => {
|
||||
const { $, text } = fixture(t);
|
||||
const card = $('#cockpit-context');
|
||||
assert.equal(card.hidden, true);
|
||||
assert.equal(card.hasAttribute('data-state'), false);
|
||||
assert.equal(text('#cockpit-context-subject'), 'CONTACTS · 250 KM');
|
||||
assert.equal($('#cockpit-context-previous').disabled, false);
|
||||
assert.equal($('#cockpit-context-next').disabled, false);
|
||||
assert.deepEqual(
|
||||
[...document.querySelectorAll('[data-context-cohort] strong')].map(
|
||||
(count) => count.textContent,
|
||||
),
|
||||
['—', '—', '—', '—'],
|
||||
);
|
||||
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
|
||||
assert.equal(
|
||||
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
|
||||
false,
|
||||
);
|
||||
assert.equal($('#cockpit-context-direction').getAttribute('style'), null);
|
||||
assert.equal(
|
||||
text('#cockpit-context-uncertainty'),
|
||||
'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR',
|
||||
);
|
||||
assert.equal(text('#cockpit-context-updated'), '--:--:--Z');
|
||||
const weather = $('#cockpit-weather-toggle');
|
||||
assert.equal(weather.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(
|
||||
weather.getAttribute('aria-label'),
|
||||
'Enable cockpit weather effects',
|
||||
);
|
||||
assert.equal(weather.title, 'Enable cockpit weather effects');
|
||||
assert.equal(text('#cockpit-weather-state'), 'OFF');
|
||||
});
|
||||
|
||||
test('a snapshot fills the card and keeps classes other code sets', async (t) => {
|
||||
const { $, text, owner, layouts } = fixture(t, snapshot());
|
||||
const flights = $('[data-context-cohort="flights"]');
|
||||
flights.classList.add('voice-highlight');
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
const card = $('#cockpit-context');
|
||||
assert.equal(card.hidden, false);
|
||||
assert.equal(card.dataset.state, 'uncertain');
|
||||
assert.equal(owner.presence.context, true);
|
||||
assert.match(
|
||||
text('#cockpit-context-subject'),
|
||||
/^RCH123 · 250\u00a0KM AIR\/SEA WINDOW$/,
|
||||
);
|
||||
assert.equal($('#cockpit-context-previous').disabled, true);
|
||||
assert.equal($('#cockpit-context-next').disabled, false);
|
||||
assert.equal(flights.querySelector('strong').textContent, '12');
|
||||
assert.equal(flights.classList.contains('unknown'), false);
|
||||
assert.equal(flights.classList.contains('voice-highlight'), true);
|
||||
const military = $('[data-context-cohort="military"]');
|
||||
assert.equal(military.querySelector('strong').textContent, '?');
|
||||
assert.equal(military.classList.contains('unknown'), true);
|
||||
assert.equal(text('#cockpit-context-nearest-label'), 'FLIGHTS · N1');
|
||||
assert.equal(
|
||||
$('#cockpit-context-nearest-label').getAttribute('aria-label'),
|
||||
'FLIGHTS · N1',
|
||||
);
|
||||
assert.match(text('#cockpit-context-distance'), /^4\.2/);
|
||||
// The tracked aircraft is not the subject: the nose-relative half dashes.
|
||||
assert.equal(text('#cockpit-context-bearing'), 'BRG —');
|
||||
const direction = $('#cockpit-context-direction');
|
||||
assert.equal(direction.style.transform, 'rotate(0deg)');
|
||||
assert.equal(direction.classList.contains('unknown'), true);
|
||||
assert.equal(direction.classList.contains('material-symbols-outlined'), true);
|
||||
assert.equal(
|
||||
text('#cockpit-context-uncertainty'),
|
||||
'1 INPUT UNKNOWN · NOT AN ALL-CLEAR',
|
||||
);
|
||||
assert.equal(text('#cockpit-context-updated'), '12:34:56Z');
|
||||
assert.equal(layouts.length, 1);
|
||||
});
|
||||
|
||||
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
|
||||
const { $, text, owner, layouts } = fixture(t, snapshot());
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
const readout = () =>
|
||||
[
|
||||
'#cockpit-context-nearest-label',
|
||||
'#cockpit-context-distance',
|
||||
'#cockpit-context-bearing',
|
||||
'#cockpit-context-updated',
|
||||
].map(text);
|
||||
const held = readout();
|
||||
owner.snapshot = snapshot({
|
||||
subjectPresent: false,
|
||||
navigation: { canPrevious: true, canNext: false },
|
||||
cohorts: [{ id: 'flights', label: 'Flights', count: 1, nearest: [] }],
|
||||
});
|
||||
updateContext.call(owner, INFO, 0);
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').dataset.state, 'lost');
|
||||
assert.equal(
|
||||
text('#cockpit-context-uncertainty'),
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
|
||||
);
|
||||
// Navigation follows the lost snapshot; the readout holds.
|
||||
assert.deepEqual(readout(), held);
|
||||
assert.equal($('#cockpit-context-previous').disabled, false);
|
||||
assert.equal($('#cockpit-context-next').disabled, true);
|
||||
assert.equal($('[data-context-cohort="flights"] strong').textContent, '12');
|
||||
// Layout reruns once on the way in, not every update.
|
||||
assert.equal(layouts.length, 2);
|
||||
});
|
||||
|
||||
test('without a snapshot the card stands by, hidden', async (t) => {
|
||||
const { $, owner, signals } = fixture(t, snapshot());
|
||||
updateContext.call(owner, INFO, 0);
|
||||
owner.snapshot = null;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').hidden, true);
|
||||
assert.equal(owner.presence.context, false);
|
||||
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
|
||||
});
|
||||
|
||||
test('the weather toggle renders its state and the action it offers', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
syncWeatherToggle.call(owner, true);
|
||||
await nextTick();
|
||||
const weather = $('#cockpit-weather-toggle');
|
||||
assert.equal(weather.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(
|
||||
weather.getAttribute('aria-label'),
|
||||
'Disable cockpit weather effects',
|
||||
);
|
||||
assert.equal(weather.title, 'Disable cockpit weather effects');
|
||||
assert.equal(text('#cockpit-weather-state'), 'ON');
|
||||
assert.equal(owner.contextView.weather, true);
|
||||
});
|
||||
|
||||
test('re-renders keep the collapse the controller presents and leave it the layout', async (t) => {
|
||||
const { $, owner } = fixture(t, snapshot());
|
||||
updateContext.call(owner, INFO, 0);
|
||||
const card = $('#cockpit-context');
|
||||
owner.presence.contextCollapsed = true;
|
||||
owner.publishPresenceView();
|
||||
card.dataset.layoutMode = 'compact-bottom';
|
||||
owner.snapshot = snapshot({ evaluatedAt: Date.UTC(2026, 9, 31, 12, 35) });
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
assert.equal(card.hidden, false);
|
||||
assert.equal(card.dataset.collapsed, 'true');
|
||||
assert.equal(card.dataset.layoutMode, 'compact-bottom');
|
||||
const toggle = $('#cockpit-context-toggle');
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(toggle.getAttribute('aria-label'), 'Expand Contact panel');
|
||||
assert.equal(toggle.title, 'Expand contact panel');
|
||||
assert.equal(toggle.textContent.trim(), 'chevron_right');
|
||||
});
|
||||
|
||||
test('the card is labelled in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, text, owner } = fixture(t);
|
||||
const subject = translate('CONTACTS · 250 KM');
|
||||
assert.notEqual(subject, 'CONTACTS · 250 KM');
|
||||
assert.equal(text('#cockpit-context-subject'), subject);
|
||||
assert.equal(text('#cockpit-weather-state'), translate('OFF'));
|
||||
syncWeatherToggle.call(owner, true);
|
||||
await nextTick();
|
||||
assert.equal(text('#cockpit-weather-state'), translate('ON'));
|
||||
assert.equal(
|
||||
$('#cockpit-weather-toggle').getAttribute('aria-label'),
|
||||
translate('Disable cockpit weather effects'),
|
||||
);
|
||||
});
|
||||
|
||||
test('collapsing the card publishes once per change', async (t) => {
|
||||
const { $, owner } = fixture(t);
|
||||
let published = 0;
|
||||
const publish = owner.publishPresenceView;
|
||||
Object.assign(owner, {
|
||||
contextCollapsed: false,
|
||||
presence: { context: true, contextCollapsed: false },
|
||||
publishPresenceView: () => {
|
||||
published += 1;
|
||||
publish();
|
||||
},
|
||||
});
|
||||
setContextCollapsed.call(owner, true);
|
||||
setContextCollapsed.call(owner, true);
|
||||
assert.equal(published, 1);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').dataset.collapsed, 'true');
|
||||
assert.equal(
|
||||
$('#cockpit-context-toggle').getAttribute('aria-expanded'),
|
||||
'false',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The Cockpit Contact card's state as it first renders. It needs only the
|
||||
* i18n module, so the controller and the view can both read it.
|
||||
*/
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Cohorts the card counts, in its order. */
|
||||
const COCKPIT_CONTEXT_COHORTS = Object.freeze([
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The card before any snapshot: navigation on, counts and readouts empty,
|
||||
* weather off. The controller keeps it, changes it in place and publishes a
|
||||
* copy; `CockpitContext` renders it.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createContextState() {
|
||||
return {
|
||||
state: null,
|
||||
subject: t('CONTACTS · 250 KM'),
|
||||
canPrevious: true,
|
||||
canNext: true,
|
||||
cohorts: Object.fromEntries(
|
||||
COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]),
|
||||
),
|
||||
nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null },
|
||||
distance: { text: '—', ariaLabel: null },
|
||||
direction: { angle: null, unknown: false },
|
||||
bearing: '—',
|
||||
uncertainty: t('AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR'),
|
||||
updated: '--:--:--Z',
|
||||
weather: false,
|
||||
};
|
||||
}
|
||||
+103
-84
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The Cockpit odometer readouts (ground speed, altitude, heading): which
|
||||
* glyphs a reading shows and which of them roll. It has no imports, so the
|
||||
* view that renders the glyphs can read it.
|
||||
*/
|
||||
|
||||
/** The shortest gap between two rolls, in ms. */
|
||||
const ROLL_THROTTLE_MS = 220;
|
||||
|
||||
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
|
||||
// narrow cell; a full digit cell would open a gap in the middle of a number.
|
||||
const ROLL_SEPARATOR = /^[,.\u00a0\u202f]$/;
|
||||
|
||||
function rollTokenClass(character) {
|
||||
return ROLL_SEPARATOR.test(character)
|
||||
? 'cockpit-roll-token is-separator'
|
||||
: 'cockpit-roll-token';
|
||||
}
|
||||
|
||||
/**
|
||||
* A readout before its first reading: no glyphs, so the view shows its
|
||||
* placeholder.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createRollingState() {
|
||||
return {
|
||||
text: undefined,
|
||||
value: NaN,
|
||||
at: NaN,
|
||||
tokens: null,
|
||||
generation: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a reading. A changed reading inside the throttle is dropped; any
|
||||
* other reading restamps the readout. A changed reading rolls each digit
|
||||
* that differs, unless it is the first or `immediate`, which draw it flat.
|
||||
*
|
||||
* @param {object} state From createRollingState or an earlier call.
|
||||
* @param {string} text The reading as shown.
|
||||
* @param {?number} numericValue Its value, for the roll direction.
|
||||
* @param {{circularRange?: ?number, immediate?: boolean, nowMs?: number}} [options]
|
||||
* @returns {object} `state` itself when its glyphs stay, else a new state
|
||||
* with a new `generation`, so every glyph is drawn afresh and its roll
|
||||
* animation restarts.
|
||||
*/
|
||||
export function advanceRollingValue(
|
||||
state,
|
||||
text,
|
||||
numericValue,
|
||||
{ circularRange = null, immediate = false, nowMs = performance.now() } = {},
|
||||
) {
|
||||
const nextText = String(text);
|
||||
const previousText = state.text;
|
||||
const previousValue = state.value;
|
||||
if (
|
||||
!immediate &&
|
||||
previousText !== undefined &&
|
||||
previousText !== nextText &&
|
||||
Number.isFinite(state.at) &&
|
||||
nowMs - state.at < ROLL_THROTTLE_MS
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
state.at = nowMs;
|
||||
state.value = Number.isFinite(numericValue) ? numericValue : NaN;
|
||||
if (previousText === nextText && state.tokens) return state;
|
||||
|
||||
const next = { ...state, text: nextText, generation: state.generation + 1 };
|
||||
if (immediate || previousText === undefined || previousText === nextText) {
|
||||
next.tokens = Array.from(nextText, (character) => ({
|
||||
className: rollTokenClass(character),
|
||||
text: character,
|
||||
track: null,
|
||||
}));
|
||||
return next;
|
||||
}
|
||||
|
||||
let delta =
|
||||
Number.isFinite(numericValue) && Number.isFinite(previousValue)
|
||||
? numericValue - previousValue
|
||||
: 0;
|
||||
if (Number.isFinite(circularRange) && circularRange > 0) {
|
||||
const halfRange = circularRange / 2;
|
||||
if (delta > halfRange) delta -= circularRange;
|
||||
else if (delta < -halfRange) delta += circularRange;
|
||||
}
|
||||
const direction = delta < 0 ? 'down' : 'up';
|
||||
const width = Math.max(previousText.length, nextText.length);
|
||||
const from = previousText.padStart(width, ' ');
|
||||
const to = nextText.padStart(width, ' ');
|
||||
next.tokens = Array.from(to, (nextCharacter, index) => {
|
||||
const previousCharacter = from[index];
|
||||
if (
|
||||
previousCharacter === nextCharacter ||
|
||||
!/\d/.test(previousCharacter) ||
|
||||
!/\d/.test(nextCharacter)
|
||||
) {
|
||||
return {
|
||||
className: rollTokenClass(nextCharacter),
|
||||
text: nextCharacter === ' ' ? '\u00a0' : nextCharacter,
|
||||
track: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
className: `${rollTokenClass(nextCharacter)} is-rolling roll-${direction}`,
|
||||
text: null,
|
||||
track:
|
||||
direction === 'up'
|
||||
? [previousCharacter, nextCharacter]
|
||||
: [nextCharacter, previousCharacter],
|
||||
};
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// src/ui/cockpitRollingValue.test.mjs — which glyphs a Cockpit odometer
|
||||
// readout shows, and which of them roll.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
advanceRollingValue,
|
||||
createRollingState,
|
||||
} from './cockpitRollingValue.js';
|
||||
|
||||
const glyphs = (state) =>
|
||||
state.tokens.map((token) => token.text ?? token.track.join('>')).join('|');
|
||||
|
||||
test('the first reading draws flat, one glyph per character', () => {
|
||||
const state = advanceRollingValue(createRollingState(), '1,250', 1250, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
assert.equal(state.text, '1,250');
|
||||
assert.equal(glyphs(state), '1|,|2|5|0');
|
||||
assert.deepEqual(
|
||||
state.tokens.map((token) => token.className),
|
||||
[
|
||||
'cockpit-roll-token',
|
||||
'cockpit-roll-token is-separator',
|
||||
'cockpit-roll-token',
|
||||
'cockpit-roll-token',
|
||||
'cockpit-roll-token',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a changed reading rolls only the digits that differ, upward when rising', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '248', 248, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
const next = advanceRollingValue(first, '251', 251, { nowMs: 1300 });
|
||||
assert.equal(next === first, false);
|
||||
assert.equal(next.generation, first.generation + 1);
|
||||
assert.equal(glyphs(next), '2|4>5|8>1');
|
||||
assert.equal(
|
||||
next.tokens[1].className,
|
||||
'cockpit-roll-token is-rolling roll-up',
|
||||
);
|
||||
assert.equal(next.tokens[0].className, 'cockpit-roll-token');
|
||||
});
|
||||
|
||||
test('a falling reading rolls down, and a longer one pads its new columns', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '99', 99, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
const down = advanceRollingValue(first, '98', 98, { nowMs: 1300 });
|
||||
assert.equal(glyphs(down), '9|8>9');
|
||||
assert.match(down.tokens[1].className, /roll-down$/);
|
||||
const wider = advanceRollingValue(down, '100', 100, { nowMs: 1600 });
|
||||
assert.equal(glyphs(wider), '1|9>0|8>0');
|
||||
const narrower = advanceRollingValue(wider, '7', 7, { nowMs: 1900 });
|
||||
assert.equal(glyphs(narrower), '\u00a0|\u00a0|7>0');
|
||||
});
|
||||
|
||||
test('heading wraps the short way round the circle', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '359', 359, {
|
||||
circularRange: 360,
|
||||
nowMs: 1000,
|
||||
});
|
||||
const next = advanceRollingValue(first, '001', 1, {
|
||||
circularRange: 360,
|
||||
nowMs: 1300,
|
||||
});
|
||||
assert.match(next.tokens[1].className, /roll-up$/);
|
||||
});
|
||||
|
||||
test('a change inside the throttle is dropped; any accepted reading restamps it', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '100', 100, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
// The same reading 150 ms later is accepted and restamps the readout.
|
||||
const same = advanceRollingValue(first, '100', 100, { nowMs: 1150 });
|
||||
assert.equal(same === first, true);
|
||||
assert.equal(same.at, 1150);
|
||||
// So a change 200 ms after the first reading is still inside the throttle.
|
||||
const early = advanceRollingValue(same, '110', 110, { nowMs: 1200 });
|
||||
assert.equal(early === same, true);
|
||||
assert.equal(early.text, '100');
|
||||
assert.equal(early.at, 1150);
|
||||
const late = advanceRollingValue(early, '110', 110, { nowMs: 1370 });
|
||||
assert.equal(late.text, '110');
|
||||
});
|
||||
|
||||
test('an immediate reading skips the throttle and draws flat', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '100', 100, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
const next = advanceRollingValue(first, '120', 120, {
|
||||
immediate: true,
|
||||
nowMs: 1010,
|
||||
});
|
||||
assert.equal(glyphs(next), '1|2|0');
|
||||
assert.equal(
|
||||
next.tokens.every((token) => token.track === null),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a reading with no value keeps rolling upward', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '---', null, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
assert.equal(Number.isNaN(first.value), true);
|
||||
const next = advanceRollingValue(first, '120', 120, { nowMs: 1300 });
|
||||
// Dashes are not digits, so nothing rolls.
|
||||
assert.equal(glyphs(next), '1|2|0');
|
||||
});
|
||||
+43
-100
@@ -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,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;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// src/ui/cockpitUtilityControls.test.mjs — the Cockpit's Display and Radio
|
||||
// launchers and the compact Radio controls render what RadioControls
|
||||
// presents, over happy-dom with the real controls bound to them.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { onKeyDown as cockpitKeyDown } from './cockpitInput.js';
|
||||
import { RadioControls } from './radioControls.js';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
import { syncSignalLayout } from './cockpitLayout.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import { useRadioStore } from './stores/radio.js';
|
||||
import CockpitUtilityControls from './views/panels/CockpitUtilityControls.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const outside = document.body.appendChild(document.createElement('button'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitUtilityControls).use(pinia);
|
||||
app.mount(host);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const calls = [];
|
||||
let enabled = false;
|
||||
let toggles = 0;
|
||||
let finishToggle = null;
|
||||
const ui = { presentationActive: true, filter: 'all' };
|
||||
const actions = {
|
||||
isRegistered: () => true,
|
||||
isEnabled: () => enabled,
|
||||
runUserAction: (operation) => operation(),
|
||||
setEnabled: (next) => {
|
||||
toggles += 1;
|
||||
return new Promise((resolve) => {
|
||||
finishToggle = () => {
|
||||
enabled = next;
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
},
|
||||
setParams: (params) => {
|
||||
calls.push(['params', params]);
|
||||
return params.filter !== 'refused';
|
||||
},
|
||||
setPanelCollapsed() {},
|
||||
setSignalCollapsed: (collapsed) => calls.push(['signal', collapsed]),
|
||||
revealStyleParameters: () => calls.push(['reveal']),
|
||||
layoutCockpit() {},
|
||||
isCockpitActive: () => true,
|
||||
signalUserCollapsed: () => false,
|
||||
scheduleLayout() {},
|
||||
getLifecycle: () => null,
|
||||
preservePanelStateDuringClear: () => true,
|
||||
};
|
||||
const controls = new RadioControls({
|
||||
elements: {
|
||||
_radioPanel: document.createElement('section'),
|
||||
// A bare tuner slider: the panel's tuner has its own tests.
|
||||
_radioTunerSlider: Object.assign(document.createElement('input'), {
|
||||
type: 'range',
|
||||
}),
|
||||
_cockpitUtilityControls: $('cockpit-utility-controls'),
|
||||
_cockpitDisplayToggleBtn: $('cockpit-display-toggle-btn'),
|
||||
_cockpitRadioToggleBtn: $('cockpit-radio-toggle-btn'),
|
||||
_cockpitRadioEnableBtn: $('cockpit-radio-enable-btn'),
|
||||
},
|
||||
radio: {
|
||||
subscribe: () => () => {},
|
||||
endTuning() {},
|
||||
getTunerStations: () => [],
|
||||
getUIState: () => ui,
|
||||
cycleStation: (direction, { rotate }) =>
|
||||
calls.push(['cycle', direction, rotate]),
|
||||
togglePlayback: () => calls.push(['playback']),
|
||||
},
|
||||
actions,
|
||||
canvas: document.createElement('canvas'),
|
||||
store: useRadioStore(pinia),
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
const render = async (state) => {
|
||||
renderRadioState.call(controls, {
|
||||
enabled: true,
|
||||
categories: [{ id: 'all', label: 'All', count: 3, color: '' }],
|
||||
filter: 'all',
|
||||
filteredCount: 3,
|
||||
stationCount: 3,
|
||||
volume: 0.8,
|
||||
audioState: 'stopped',
|
||||
...state,
|
||||
});
|
||||
await nextTick();
|
||||
};
|
||||
return {
|
||||
$,
|
||||
controls,
|
||||
calls,
|
||||
ui,
|
||||
render,
|
||||
outside,
|
||||
finishToggle: () => finishToggle(),
|
||||
toggles: () => toggles,
|
||||
};
|
||||
}
|
||||
|
||||
const control = (button) => button.closest('.cockpit-utility-control');
|
||||
|
||||
test('before RadioControls presents, both popovers are closed and the controls start idle', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
for (const kind of ['display', 'radio']) {
|
||||
const toggle = $(`cockpit-${kind}-toggle-btn`);
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(toggle.textContent, '◀');
|
||||
assert.equal($(`cockpit-${kind}-panel`).hidden, true);
|
||||
assert.equal(control(toggle).classList.contains('is-expanded'), false);
|
||||
}
|
||||
assert.equal(
|
||||
$('cockpit-display-toggle-btn').getAttribute('aria-label'),
|
||||
'Expand Cockpit display options',
|
||||
);
|
||||
assert.equal(
|
||||
$('cockpit-radio-toggle-btn').title,
|
||||
'Expand Cockpit Radio controls',
|
||||
);
|
||||
assert.equal($('cockpit-radio-station').textContent, 'READY');
|
||||
const enable = $('cockpit-radio-enable-btn');
|
||||
assert.equal(enable.textContent, 'ENABLE');
|
||||
assert.equal(enable.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(enable.hasAttribute('aria-busy'), false);
|
||||
assert.equal($('cockpit-radio-play-btn').disabled, true);
|
||||
assert.equal($('cockpit-radio-play-btn').textContent, '▶');
|
||||
assert.equal($('cockpit-radio-volume').value, '80');
|
||||
assert.equal($('cockpit-radio-volume').disabled, true);
|
||||
assert.equal($('cockpit-radio-volume-value').textContent, '80%');
|
||||
assert.equal(
|
||||
document.querySelectorAll('[data-cockpit-display-slot]').length,
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
test('a launcher opens its popover, closes the other, and Escape returns focus', async (t) => {
|
||||
const { $, controls, calls } = fixture(t);
|
||||
// The application shell takes the Display launcher's clicks.
|
||||
controls._setCockpitDisclosure('display', true);
|
||||
await nextTick();
|
||||
assert.equal(controls._cockpitDisclosureOpen('display'), true);
|
||||
assert.equal($('cockpit-display-panel').hidden, false);
|
||||
assert.equal($('cockpit-display-toggle-btn').textContent, '▶');
|
||||
assert.equal(
|
||||
$('cockpit-display-toggle-btn').getAttribute('aria-label'),
|
||||
'Collapse Cockpit display options',
|
||||
);
|
||||
assert.equal(
|
||||
control($('cockpit-display-toggle-btn')).classList.contains('is-expanded'),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls.slice(0, 2), [['signal', true], ['reveal']]);
|
||||
$('cockpit-radio-toggle-btn').click();
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-display-panel').hidden, true);
|
||||
assert.equal($('cockpit-radio-panel').hidden, false);
|
||||
assert.equal(
|
||||
$('cockpit-radio-toggle-btn').getAttribute('aria-expanded'),
|
||||
'true',
|
||||
);
|
||||
$('cockpit-radio-play-btn').dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-radio-panel').hidden, true);
|
||||
assert.equal(document.activeElement === $('cockpit-radio-toggle-btn'), true);
|
||||
assert.deepEqual(calls.at(-1), ['signal', false]);
|
||||
});
|
||||
|
||||
test('a press outside the Cockpit controls closes the open popover', async (t) => {
|
||||
const { $, controls, outside } = fixture(t);
|
||||
controls._setCockpitDisclosure('radio', true);
|
||||
await nextTick();
|
||||
$('cockpit-radio-volume').dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-radio-panel').hidden, false);
|
||||
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-radio-panel').hidden, true);
|
||||
assert.equal(controls._cockpitDisclosureOpen('radio'), false);
|
||||
});
|
||||
|
||||
test('the compact controls render the presented Radio state', async (t) => {
|
||||
const { $, render } = fixture(t);
|
||||
await render({
|
||||
selected: { id: 's1', name: 'KUTX' },
|
||||
audioState: 'playing',
|
||||
volume: 0.35,
|
||||
});
|
||||
assert.equal($('cockpit-radio-station').textContent, 'KUTX');
|
||||
const enable = $('cockpit-radio-enable-btn');
|
||||
assert.equal(enable.textContent, 'DISABLE');
|
||||
assert.equal(enable.classList.contains('active'), true);
|
||||
assert.equal(enable.classList.contains('panel-layer-toggle'), true);
|
||||
assert.equal(enable.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(enable.getAttribute('aria-label'), 'Disable Radio');
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(enable.disabled, false);
|
||||
const play = $('cockpit-radio-play-btn');
|
||||
assert.equal(play.disabled, false);
|
||||
assert.equal(play.textContent, 'Ⅱ');
|
||||
assert.equal(play.classList.contains('active'), true);
|
||||
assert.equal(play.getAttribute('aria-label'), 'Pause selected radio station');
|
||||
assert.equal(play.title, 'Pause');
|
||||
assert.equal($('cockpit-radio-prev-btn').disabled, false);
|
||||
assert.equal($('cockpit-radio-volume').value, '35');
|
||||
assert.equal($('cockpit-radio-volume').disabled, false);
|
||||
assert.equal($('cockpit-radio-volume-value').textContent, '35%');
|
||||
await render({ enabled: false, selected: null, loading: true });
|
||||
assert.equal($('cockpit-radio-station').textContent, 'SYNCING');
|
||||
assert.equal(enable.textContent, 'ENABLE');
|
||||
assert.equal(enable.classList.contains('active'), false);
|
||||
assert.equal(play.disabled, true);
|
||||
assert.equal(play.textContent, '▶');
|
||||
assert.equal($('cockpit-radio-volume').disabled, true);
|
||||
});
|
||||
|
||||
test('dragging the volume shows the value at once and a render leaves the held slider alone', async (t) => {
|
||||
const { $, calls, render } = fixture(t);
|
||||
await render({ volume: 0.8 });
|
||||
const volume = $('cockpit-radio-volume');
|
||||
volume.focus();
|
||||
volume.value = '42';
|
||||
volume.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-radio-volume-value').textContent, '42%');
|
||||
assert.deepEqual(calls.at(-1), ['params', { volume: 0.42 }]);
|
||||
// A render reporting an older volume while the slider is held.
|
||||
await render({ volume: 0.8 });
|
||||
assert.equal(volume.value, '42');
|
||||
});
|
||||
|
||||
test('the compact transport steps stations without rotating the camera', (t) => {
|
||||
const { $, calls } = fixture(t);
|
||||
for (const id of [
|
||||
'cockpit-radio-prev-btn',
|
||||
'cockpit-radio-play-btn',
|
||||
'cockpit-radio-next-btn',
|
||||
]) {
|
||||
$(id).disabled = false;
|
||||
$(id).click();
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
['cycle', -1, false],
|
||||
['playback'],
|
||||
['cycle', 1, false],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the station filter answers with the tag the directory keeps', (t) => {
|
||||
const { controls, calls, ui } = fixture(t);
|
||||
assert.equal(controls.store.act('setRadioFilter', 'jazz'), 'jazz');
|
||||
assert.equal(controls.store.act('setRadioFilter', 'refused'), 'all');
|
||||
ui.presentationActive = false;
|
||||
assert.equal(controls.store.act('setRadioFilter', 'rock'), 'all');
|
||||
assert.deepEqual(calls, [
|
||||
['params', { filter: 'jazz' }],
|
||||
['params', { filter: 'refused' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a destroyed RadioControls answers no controls', (t) => {
|
||||
const { controls, calls } = fixture(t);
|
||||
controls.destroy();
|
||||
assert.equal(controls.store.act('setRadioFilter', 'jazz'), undefined);
|
||||
controls.store.act('cycleRadio', 1);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('the Cockpit Enable button is marked busy while its own toggle runs', async (t) => {
|
||||
const { $, render, finishToggle, toggles } = fixture(t);
|
||||
await render({ enabled: false });
|
||||
const enable = $('cockpit-radio-enable-btn');
|
||||
// A double press lands before the busy state renders; only one toggle runs.
|
||||
enable.click();
|
||||
enable.click();
|
||||
assert.equal(toggles(), 1);
|
||||
await nextTick();
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(enable.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(enable.disabled, false);
|
||||
// A second press while busy starts nothing.
|
||||
enable.click();
|
||||
assert.equal(toggles(), 1);
|
||||
finishToggle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'false');
|
||||
});
|
||||
|
||||
test('the launchers read in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, controls } = fixture(t);
|
||||
const before = $('cockpit-radio-toggle-btn').getAttribute('aria-label');
|
||||
controls._setCockpitDisclosure('radio', true);
|
||||
await nextTick();
|
||||
const after = $('cockpit-radio-toggle-btn').getAttribute('aria-label');
|
||||
assert.notEqual(before, 'Expand Cockpit Radio controls');
|
||||
assert.notEqual(after, before);
|
||||
assert.notEqual(after, 'Collapse Cockpit Radio controls');
|
||||
});
|
||||
|
||||
test('Cockpit Escape defers to a popover the moment its disclosure opens', async (t) => {
|
||||
const { controls } = fixture(t);
|
||||
let exits = 0;
|
||||
const cockpit = {
|
||||
active: true,
|
||||
isUtilityDisclosureOpen: () =>
|
||||
controls._cockpitDisclosureOpen('display') ||
|
||||
controls._cockpitDisclosureOpen('radio'),
|
||||
exit: () => (exits += 1),
|
||||
};
|
||||
const escape = () =>
|
||||
cockpitKeyDown.call(
|
||||
cockpit,
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
// Dispatched in the same task, before Vue renders the open popover.
|
||||
controls._setCockpitDisclosure('radio', true);
|
||||
escape();
|
||||
assert.equal(exits, 0);
|
||||
controls._setCockpitDisclosure('radio', false);
|
||||
escape();
|
||||
assert.equal(exits, 1);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
test('the layout pass’s measured fit hides the collapsed control and marks the strip', async () => {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitUtilityControls).use(pinia);
|
||||
app.mount(host);
|
||||
const radio = useRadioStore(pinia);
|
||||
const cockpit = useCockpitStore(pinia);
|
||||
const strip = document.getElementById('cockpit-utility-controls');
|
||||
const [display, radioControl] = strip.querySelectorAll(
|
||||
'.cockpit-utility-control',
|
||||
);
|
||||
const layout = (fit) => ({
|
||||
top: '96.0px',
|
||||
maxHeight: '300.00px',
|
||||
expandedMaxHeight: '300.00px',
|
||||
primaryOnly: false,
|
||||
hideCollapsed: false,
|
||||
...fit,
|
||||
});
|
||||
// Until the layout pass runs, neither control carries aria-hidden.
|
||||
assert.equal(display.hasAttribute('aria-hidden'), false);
|
||||
radio.publishUtility({ display: false, radio: true });
|
||||
cockpit.publishUtilityLayout(
|
||||
layout({ primaryOnly: true, hideCollapsed: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal(strip.classList.contains('layout-primary-only'), true);
|
||||
assert.equal(display.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
|
||||
// With nothing expanded there is no collapsed sibling to hide.
|
||||
radio.publishUtility({ display: false, radio: false });
|
||||
await nextTick();
|
||||
assert.equal(display.getAttribute('aria-hidden'), 'false');
|
||||
cockpit.publishUtilityLayout(layout());
|
||||
await nextTick();
|
||||
assert.equal(strip.classList.contains('layout-primary-only'), false);
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test('the real layout pass drives the mounted controls: compact hides the collapsed sibling, Cyber shows both', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitUtilityControls).use(pinia);
|
||||
app.mount(host);
|
||||
const radio = useRadioStore(pinia);
|
||||
const cockpit = useCockpitStore(pinia);
|
||||
const signalStream = document.body.appendChild(
|
||||
document.createElement('aside'),
|
||||
);
|
||||
const owner = {
|
||||
signalStream,
|
||||
signalCollapsed: false,
|
||||
contextCollapsed: false,
|
||||
context: null,
|
||||
publishUtilityLayout: (layout) => cockpit.publishUtilityLayout(layout),
|
||||
syncContextLayout() {},
|
||||
};
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
let compact = true;
|
||||
window.matchMedia = (query) => ({ matches: compact, media: query });
|
||||
t.after(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
delete document.documentElement.dataset.uiTheme;
|
||||
document.body.style.removeProperty('opacity');
|
||||
document.documentElement.style.removeProperty('opacity');
|
||||
app.unmount();
|
||||
});
|
||||
const strip = document.getElementById('cockpit-utility-controls');
|
||||
const [display, radioControl] = strip.querySelectorAll(
|
||||
'.cockpit-utility-control',
|
||||
);
|
||||
radio.publishUtility({ display: false, radio: true });
|
||||
await nextTick();
|
||||
syncSignalLayout.call(owner);
|
||||
await nextTick();
|
||||
assert.equal(display.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
|
||||
assert.equal(cockpit.utilityLayout.top, null);
|
||||
// Compact hides the sibling without marking the strip primary-only.
|
||||
assert.equal(strip.classList.contains('layout-primary-only'), false);
|
||||
// Cyber aligns the strip with the Data panel and keeps both controls.
|
||||
compact = false;
|
||||
document.documentElement.dataset.uiTheme = 'cyber';
|
||||
const stack = document.body.appendChild(document.createElement('div'));
|
||||
stack.id = 'left-panel-stack';
|
||||
const dataPanel = stack.appendChild(document.createElement('section'));
|
||||
dataPanel.id = 'data-panel';
|
||||
for (const node of [
|
||||
dataPanel,
|
||||
stack,
|
||||
document.body,
|
||||
document.documentElement,
|
||||
])
|
||||
node.style.opacity = '1';
|
||||
dataPanel.getBoundingClientRect = () => ({
|
||||
top: 120,
|
||||
bottom: 400,
|
||||
left: 0,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 280,
|
||||
});
|
||||
syncSignalLayout.call(owner);
|
||||
await nextTick();
|
||||
assert.equal(display.getAttribute('aria-hidden'), 'false');
|
||||
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
|
||||
assert.equal(cockpit.utilityLayout.top, '120.00px');
|
||||
assert.equal(strip.classList.contains('layout-primary-only'), false);
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
// src/ui/commandDock.test.mjs — the command dock and the scene chrome render
|
||||
// what VisualSettings publishes, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { useLocationStore } from './stores/location.js';
|
||||
import { LocationControls } from './locationControls.js';
|
||||
import { MOBILE_TABS, initMobileShell } from './mobileShell.js';
|
||||
import CommandDockView from './views/CommandDockView.vue';
|
||||
import SceneChromeView from './views/SceneChromeView.vue';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp({
|
||||
render: () => [h(SceneChromeView), h(CommandDockView)],
|
||||
}).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useDisplayStore(pinia);
|
||||
// VisualSettings publishes through its store; the method needs no more.
|
||||
const settings = Object.assign(Object.create(VisualSettings.prototype), {
|
||||
store,
|
||||
activeStyle: 'normal',
|
||||
});
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const active = () =>
|
||||
[...document.querySelectorAll('.style-btn.active')].map(
|
||||
(button) => button.dataset.style,
|
||||
);
|
||||
return { $, settings, active, pinia };
|
||||
}
|
||||
|
||||
test('before VisualSettings publishes, the dock shows NORMAL', (t) => {
|
||||
const { $, active } = fixture(t);
|
||||
assert.deepEqual(active(), ['normal']);
|
||||
assert.equal($('#style-mini-value').textContent, 'NORMAL');
|
||||
assert.equal($('#active-style-name').textContent, 'NORMAL');
|
||||
});
|
||||
|
||||
test('the active style marks its button and labels the dock and the corner', async (t) => {
|
||||
const { $, settings, active } = fixture(t);
|
||||
settings._publishActiveStyle('thermal');
|
||||
await nextTick();
|
||||
assert.deepEqual(active(), ['thermal']);
|
||||
assert.equal($('#style-mini-value').textContent, 'FLIR');
|
||||
assert.equal($('#active-style-name').textContent, 'FLIR');
|
||||
settings.activeStyle = 'unlisted';
|
||||
settings._publishActiveStyle();
|
||||
await nextTick();
|
||||
assert.deepEqual(active(), []);
|
||||
assert.equal($('#style-mini-value').textContent, 'UNLISTED');
|
||||
settings._publishActiveStyle('normal');
|
||||
await nextTick();
|
||||
assert.deepEqual(active(), ['normal']);
|
||||
});
|
||||
|
||||
test('the style buttons keep classes other code sets', async (t) => {
|
||||
const { $, settings } = fixture(t);
|
||||
const noir = $('.style-btn[data-style="noir"]');
|
||||
noir.classList.add('voice-highlight');
|
||||
settings._publishActiveStyle('noir');
|
||||
await nextTick();
|
||||
assert.equal(noir.classList.contains('voice-highlight'), true);
|
||||
assert.equal(noir.classList.contains('active'), true);
|
||||
});
|
||||
|
||||
test('the dock and the corner label the style in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, settings } = fixture(t);
|
||||
assert.equal($('#style-mini-value').textContent, 'ОБЫЧНЫЙ');
|
||||
assert.equal($('#active-style-name').textContent, 'ОБЫЧНЫЙ');
|
||||
settings._publishActiveStyle('noir');
|
||||
await nextTick();
|
||||
assert.equal($('#style-mini-value').textContent, 'НУАР');
|
||||
assert.equal($('#active-style-name').textContent, 'НУАР');
|
||||
});
|
||||
|
||||
const chipView = (activeId, { warn = false, text = 'OSM' } = {}) => ({
|
||||
chips: [
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing',
|
||||
available: false,
|
||||
active: false,
|
||||
requirement: 'ION',
|
||||
title: 'Needs a key',
|
||||
ariaLabel: 'Bing unavailable: Needs a key',
|
||||
},
|
||||
...['esri-imagery', 'osm'].map((id) => ({
|
||||
id,
|
||||
label: id.toUpperCase(),
|
||||
available: true,
|
||||
active: id === activeId,
|
||||
requirement: '',
|
||||
title: id.toUpperCase(),
|
||||
ariaLabel: null,
|
||||
})),
|
||||
],
|
||||
status: { text, warn },
|
||||
});
|
||||
|
||||
test('the map source row renders the published chips and selects through the controls', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#map-stack-status').textContent, '3D');
|
||||
assert.equal(document.querySelectorAll('.map-stack-chip').length, 0);
|
||||
const store = useDisplayStore(pinia);
|
||||
const selected = [];
|
||||
store.connectMapSource({
|
||||
select: async (id) => selected.push(id),
|
||||
});
|
||||
store.publishMapSource(chipView('osm'));
|
||||
await nextTick();
|
||||
const chips = [
|
||||
...document.querySelectorAll('#map-stack-chips .map-stack-chip'),
|
||||
];
|
||||
assert.deepEqual(
|
||||
chips.map((chip) => chip.dataset.stackId),
|
||||
['bing-aerial', 'esri-imagery', 'osm'],
|
||||
);
|
||||
const [bing, esri, osm] = chips;
|
||||
assert.equal(bing.classList.contains('unavailable'), true);
|
||||
assert.equal(
|
||||
bing.disabled,
|
||||
false,
|
||||
'unavailable chips stay keyboard-reachable',
|
||||
);
|
||||
assert.equal(bing.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(
|
||||
bing.getAttribute('aria-label'),
|
||||
'Bing unavailable: Needs a key',
|
||||
);
|
||||
assert.equal(bing.title, 'Needs a key');
|
||||
assert.equal(bing.querySelector('.map-stack-chip-req').textContent, 'ION');
|
||||
assert.equal(osm.classList.contains('active'), true);
|
||||
assert.equal(osm.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(osm.hasAttribute('aria-label'), false);
|
||||
assert.equal(esri.querySelector('.map-stack-chip-req'), null);
|
||||
bing.click();
|
||||
esri.click();
|
||||
assert.deepEqual(selected, ['esri-imagery']);
|
||||
esri.classList.add('voice-highlight');
|
||||
store.publishMapSource(chipView('esri-imagery', { text: '...', warn: true }));
|
||||
await nextTick();
|
||||
assert.equal(esri.classList.contains('active'), true);
|
||||
assert.equal(esri.classList.contains('voice-highlight'), true);
|
||||
assert.equal(osm.classList.contains('active'), false);
|
||||
assert.equal($('#map-stack-status').textContent, '...');
|
||||
assert.equal($('#map-stack-status').classList.contains('warn'), true);
|
||||
store.publishMapSource(null);
|
||||
await nextTick();
|
||||
assert.equal($('#map-stack-status').textContent, '3D');
|
||||
assert.equal($('#map-stack-status').classList.contains('warn'), false);
|
||||
assert.equal(document.querySelectorAll('.map-stack-chip').length, 0);
|
||||
});
|
||||
|
||||
test('the ORBIT indicator sits on the body while offered and lights while orbiting', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#orbit-indicator'), null);
|
||||
const store = useLocationStore(pinia);
|
||||
const controls = new LocationControls({
|
||||
elements: {
|
||||
search: $('#location-search'),
|
||||
},
|
||||
cities: {},
|
||||
getExpandedCity: () => null,
|
||||
onCity() {},
|
||||
onPoi() {},
|
||||
onSearch() {},
|
||||
onReset() {},
|
||||
publish: (view) => store.publish(view),
|
||||
requestFrame() {},
|
||||
cancelFrame() {},
|
||||
});
|
||||
controls.showOrbit(false);
|
||||
await nextTick();
|
||||
const indicator = $('#orbit-indicator');
|
||||
assert.equal(indicator.parentElement, document.body);
|
||||
assert.equal(indicator.textContent, '↻ ORBIT');
|
||||
assert.equal(indicator.querySelector('.orbit-icon').textContent, '↻');
|
||||
assert.equal(indicator.classList.contains('active'), false);
|
||||
controls.showOrbit(true);
|
||||
await nextTick();
|
||||
assert.equal(indicator.classList.contains('active'), true);
|
||||
controls.showOrbit(false);
|
||||
await nextTick();
|
||||
assert.equal(indicator.classList.contains('active'), false);
|
||||
assert.ok($('#orbit-indicator') === indicator, 'stopping keeps it in place');
|
||||
controls.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#orbit-indicator'), null);
|
||||
});
|
||||
|
||||
test('the location tray renders what LocationControls publishes', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#location-mini-city').textContent, '📍 Location: --');
|
||||
assert.equal($('#location-mini-poi').textContent, 'Landmark: --');
|
||||
const store = useLocationStore(pinia);
|
||||
const calls = [];
|
||||
const frames = [];
|
||||
const controls = new LocationControls({
|
||||
elements: {
|
||||
search: $('#location-search'),
|
||||
},
|
||||
cities: {
|
||||
paris: { name: 'Paris', pois: [{ name: 'Louvre' }, { name: 'Eiffel' }] },
|
||||
rome: { name: 'Rome', pois: [{ name: 'Colosseum' }] },
|
||||
},
|
||||
getExpandedCity: () => null,
|
||||
onCity: (id) => calls.push(['city', id]),
|
||||
onPoi: (id, index) => calls.push(['poi', id, index]),
|
||||
onSearch() {},
|
||||
onReset() {},
|
||||
publish: (view) => store.publish(view),
|
||||
requestFrame: (fn) => frames.push(fn),
|
||||
cancelFrame() {},
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
store.connect(controls);
|
||||
await nextTick();
|
||||
const pills = [
|
||||
...document.querySelectorAll('#location-pills .location-pill'),
|
||||
];
|
||||
assert.deepEqual(
|
||||
pills.map((pill) => `${pill.dataset.locationId}:${pill.textContent}`),
|
||||
['paris:Paris', 'rome:Rome'],
|
||||
);
|
||||
pills[1].click();
|
||||
assert.deepEqual(calls, [['city', 'rome']]);
|
||||
pills[1].classList.add('voice-highlight');
|
||||
controls.highlightCity('rome');
|
||||
controls.showPois('paris');
|
||||
await nextTick();
|
||||
assert.equal(pills[1].classList.contains('active'), true);
|
||||
assert.equal(pills[1].classList.contains('voice-highlight'), true);
|
||||
const row = $('#poi-row');
|
||||
const pois = [...row.querySelectorAll('.poi-pill')];
|
||||
assert.deepEqual(
|
||||
pois.map((poi) => poi.textContent),
|
||||
['QLouvre', 'WEiffel'],
|
||||
);
|
||||
assert.equal(row.classList.contains('expanded'), false);
|
||||
frames.shift()();
|
||||
await nextTick();
|
||||
assert.equal(row.classList.contains('expanded'), true);
|
||||
assert.equal($('#location-bar-divider').classList.contains('visible'), true);
|
||||
controls.highlightPoi(1);
|
||||
await nextTick();
|
||||
assert.equal(pois[1].classList.contains('active'), true);
|
||||
pois[1].click();
|
||||
assert.deepEqual(calls.at(-1), ['poi', 'paris', 1]);
|
||||
// Switching cities while the row is open swaps its pills and keeps it open.
|
||||
controls.showPois('rome');
|
||||
await nextTick();
|
||||
assert.equal(row.classList.contains('expanded'), true);
|
||||
const romePois = [...row.querySelectorAll('.poi-pill')];
|
||||
assert.deepEqual(
|
||||
romePois.map((poi) => poi.textContent),
|
||||
['QColosseum'],
|
||||
);
|
||||
assert.equal(romePois[0].classList.contains('active'), false);
|
||||
romePois[0].click();
|
||||
assert.deepEqual(calls.at(-1), ['poi', 'rome', 0]);
|
||||
frames.shift()();
|
||||
controls.renderStatus({
|
||||
city: { name: 'Paris' },
|
||||
currentPoi: { name: 'Eiffel' },
|
||||
});
|
||||
controls.setSearchError('No match');
|
||||
await nextTick();
|
||||
assert.match($('#location-mini-city').textContent, /Paris/);
|
||||
assert.match($('#location-mini-poi').textContent, /Eiffel/);
|
||||
assert.equal($('#location-search-error').textContent, 'No match');
|
||||
const search = $('#location-search');
|
||||
assert.equal(search.getAttribute('aria-invalid'), 'true');
|
||||
assert.equal(
|
||||
search.getAttribute('aria-describedby'),
|
||||
'location-search-error',
|
||||
);
|
||||
controls.setSearchError(null);
|
||||
controls.hidePois();
|
||||
await nextTick();
|
||||
assert.equal(search.hasAttribute('aria-invalid'), false);
|
||||
assert.equal(search.hasAttribute('aria-describedby'), false);
|
||||
assert.equal($('#location-search-error').textContent, '');
|
||||
assert.equal(row.classList.contains('expanded'), false);
|
||||
assert.equal($('#location-bar-divider').classList.contains('visible'), false);
|
||||
});
|
||||
|
||||
// These compare nodes by identity in a boolean: a failing assertion on a
|
||||
// happy-dom node would try to print the whole document.
|
||||
test('the phone tab bar joins the end of the page while the shell publishes', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#mobile-tab-bar') === null, true);
|
||||
assert.equal($('#mobile-sheet-backdrop') === null, true);
|
||||
// The scene adds its credits after the views mount; the bar follows them.
|
||||
const credits = document.body.appendChild(document.createElement('div'));
|
||||
const store = useDisplayStore(pinia);
|
||||
store.publishPhoneShell({ enabled: true, sheet: null, tabs: MOBILE_TABS });
|
||||
await nextTick();
|
||||
const bar = $('#mobile-tab-bar');
|
||||
const backdrop = $('#mobile-sheet-backdrop');
|
||||
assert.equal(document.body.lastElementChild === bar, true);
|
||||
assert.equal(bar.previousElementSibling === backdrop, true);
|
||||
assert.equal(backdrop.previousElementSibling === credits, true);
|
||||
assert.equal(bar.getAttribute('aria-label'), 'Panels');
|
||||
assert.equal(bar.getAttribute('aria-hidden'), 'false');
|
||||
assert.equal(backdrop.hidden, true);
|
||||
const tabs = [...bar.querySelectorAll('.mobile-tab')];
|
||||
assert.deepEqual(
|
||||
tabs.map(
|
||||
(tab) =>
|
||||
`${tab.dataset.mobileSheet}:${tab.querySelector('.mobile-tab-icon').textContent}:${tab.querySelector('.mobile-tab-label').textContent}`,
|
||||
),
|
||||
[
|
||||
'layers:layers:Layers',
|
||||
'cameras:videocam:Cameras',
|
||||
'context:radar:Context',
|
||||
'search:search:Search',
|
||||
'more:more_horiz:More',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
tabs.map((tab) => tab.getAttribute('aria-expanded')),
|
||||
['false', 'false', 'false', 'false', 'false'],
|
||||
);
|
||||
store.publishPhoneShell({
|
||||
enabled: true,
|
||||
sheet: 'context',
|
||||
tabs: MOBILE_TABS,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(backdrop.hidden, false);
|
||||
assert.deepEqual(
|
||||
tabs.map((tab) => tab.getAttribute('aria-expanded')),
|
||||
['false', 'false', 'true', 'false', 'false'],
|
||||
);
|
||||
store.publishPhoneShell({ enabled: false, sheet: null, tabs: MOBILE_TABS });
|
||||
await nextTick();
|
||||
assert.equal(bar.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(backdrop.hidden, true);
|
||||
assert.deepEqual(
|
||||
tabs.map((tab) => tab.getAttribute('aria-expanded')),
|
||||
['false', 'false', 'false', 'false', 'false'],
|
||||
);
|
||||
store.publishPhoneShell(null);
|
||||
await nextTick();
|
||||
assert.equal($('#mobile-tab-bar') === null, true);
|
||||
assert.equal($('#mobile-sheet-backdrop') === null, true);
|
||||
});
|
||||
|
||||
test('the phone tabs are labelled in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, pinia } = fixture(t);
|
||||
useDisplayStore(pinia).publishPhoneShell({
|
||||
enabled: true,
|
||||
sheet: null,
|
||||
tabs: MOBILE_TABS,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal($('#mobile-tab-bar').getAttribute('aria-label'), 'Панели');
|
||||
assert.equal(
|
||||
$('#mobile-tab-bar [data-mobile-sheet="layers"] .mobile-tab-label')
|
||||
.textContent,
|
||||
'Слои',
|
||||
);
|
||||
});
|
||||
|
||||
test('closing a sheet returns focus to its tab once the tab bar shows it closed', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
const store = useDisplayStore(pinia);
|
||||
const init = () =>
|
||||
initMobileShell({
|
||||
documentRef: document,
|
||||
matchMedia: () => ({ matches: true }),
|
||||
ResizeObserverImpl: null,
|
||||
requestFrame: () => 0,
|
||||
setPanelCollapsed() {},
|
||||
publish: (view) => store.publishPhoneShell(view),
|
||||
afterPublish: (callback) => {
|
||||
void nextTick(callback);
|
||||
},
|
||||
});
|
||||
let shell = init();
|
||||
t.after(() => shell.destroy());
|
||||
await nextTick();
|
||||
const tab = $('#mobile-tab-bar [data-mobile-sheet="search"]');
|
||||
tab.focus();
|
||||
tab.click();
|
||||
await nextTick();
|
||||
assert.equal(tab.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(document.activeElement?.id, 'location-search');
|
||||
let expandedAtFocus = null;
|
||||
tab.addEventListener('focus', () => {
|
||||
expandedAtFocus = tab.getAttribute('aria-expanded');
|
||||
});
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.equal(document.activeElement === tab, true);
|
||||
assert.equal(expandedAtFocus, 'false');
|
||||
assert.equal($('#mobile-sheet-backdrop').hidden, true);
|
||||
// Reopening before the closed tab bar renders keeps focus in the sheet.
|
||||
const layers = $('#mobile-tab-bar [data-mobile-sheet="layers"]');
|
||||
const inSheet = document.body.appendChild(document.createElement('button'));
|
||||
const escape = () =>
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
layers.focus();
|
||||
layers.click();
|
||||
inSheet.focus();
|
||||
escape();
|
||||
layers.click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.equal(document.body.dataset.mobileSheet, 'layers');
|
||||
assert.equal(document.activeElement === inSheet, true);
|
||||
// So does destroying the shell with a sheet open.
|
||||
escape();
|
||||
layers.focus();
|
||||
layers.click();
|
||||
inSheet.focus();
|
||||
shell.destroy();
|
||||
shell = init();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.equal(document.activeElement === inSheet, true);
|
||||
// A destroyed shell takes its bar along; a new one brings it back.
|
||||
shell.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#mobile-tab-bar') === null, true);
|
||||
shell = init();
|
||||
await nextTick();
|
||||
assert.equal(document.querySelectorAll('#mobile-tab-bar').length, 1);
|
||||
$('#mobile-tab-bar [data-mobile-sheet="more"]').click();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
$('#mobile-tab-bar [data-mobile-sheet="more"]').getAttribute(
|
||||
'aria-expanded',
|
||||
),
|
||||
'true',
|
||||
);
|
||||
assert.equal($('#mobile-sheet-backdrop').hidden, false);
|
||||
});
|
||||
|
||||
test('the search toggle opens the field, and a lookup marks it', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
const store = useLocationStore(pinia);
|
||||
const controls = new LocationControls({
|
||||
elements: { search: $('#location-search') },
|
||||
cities: {},
|
||||
getExpandedCity: () => null,
|
||||
onCity() {},
|
||||
onPoi() {},
|
||||
onSearch() {},
|
||||
onReset() {},
|
||||
publish: (view) => store.publish(view),
|
||||
requestFrame() {},
|
||||
cancelFrame() {},
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
store.connect(controls);
|
||||
await nextTick();
|
||||
const field = $('#location-search');
|
||||
assert.equal(field.classList.contains('expanded'), false);
|
||||
$('#search-toggle').click();
|
||||
await nextTick();
|
||||
assert.equal(field.classList.contains('expanded'), true);
|
||||
assert.equal(document.activeElement, field);
|
||||
controls.setSearching(true);
|
||||
await nextTick();
|
||||
assert.equal(field.classList.contains('searching'), true);
|
||||
controls.settleSearch();
|
||||
await nextTick();
|
||||
assert.equal(field.classList.contains('searching'), false);
|
||||
assert.equal(field.classList.contains('expanded'), false);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,689 @@
|
||||
// src/ui/displayPanel.test.mjs — the Display panel renders bloom and sharpen
|
||||
// from what VisualSettings publishes, and shows its initial markup before.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import DisplayPanel from './views/panels/DisplayPanel.vue';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { initGeoFileImport } from './geoFileImport.js';
|
||||
import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
|
||||
// Core's classes on an element, without the kit's `ui-` classes.
|
||||
const coreClasses = (el) =>
|
||||
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(DisplayPanel).use(pinia);
|
||||
app.mount(host);
|
||||
let mounted = true;
|
||||
const unmount = () => {
|
||||
if (mounted) app.unmount();
|
||||
mounted = false;
|
||||
};
|
||||
t.after(unmount);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
return { store: useDisplayStore(pinia), $, unmount };
|
||||
}
|
||||
|
||||
/** A VisualSettings over stand-in effects, publishing to `store`. */
|
||||
function settings(store) {
|
||||
const owner = Object.create(VisualSettings.prototype);
|
||||
const effects = {
|
||||
bloomEnabled: false,
|
||||
sharpenEnabled: false,
|
||||
bloomIntensity: 0,
|
||||
sharpenIntensity: 0.49,
|
||||
applyBloomIntensity(value) {
|
||||
this.bloomIntensity = value;
|
||||
},
|
||||
applySharpenIntensity(value) {
|
||||
this.sharpenIntensity = value;
|
||||
},
|
||||
setBloomEnabled(on) {
|
||||
this.bloomEnabled = on;
|
||||
},
|
||||
setSharpenEnabled(on) {
|
||||
this.sharpenEnabled = on;
|
||||
},
|
||||
syncBloomEnabled() {},
|
||||
};
|
||||
const shares = [];
|
||||
Object.assign(owner, {
|
||||
store,
|
||||
_visualEffects: effects,
|
||||
_sharpenPct: 49,
|
||||
services: { governorRequestRender() {} },
|
||||
_syncShareState: () => shares.push(true),
|
||||
_layoutRightPanels() {},
|
||||
});
|
||||
return { owner, effects, shares };
|
||||
}
|
||||
|
||||
test('before VisualSettings renders, the panel shows its initial markup', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
assert.equal($('bloom-toggle').className, 'pp-toggle-btn');
|
||||
assert.equal($('bloom-slider-row').className, 'pp-slider-row');
|
||||
assert.equal($('bloom-intensity-slider').value, '0');
|
||||
assert.equal($('bloom-intensity-value').textContent, '0%');
|
||||
assert.equal($('sharpen-intensity-slider').value, '49');
|
||||
assert.equal($('sharpen-intensity-value').textContent, '49%');
|
||||
});
|
||||
|
||||
test('bloom and sharpen render their state, slider and readout', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner, effects, shares } = settings(store);
|
||||
|
||||
owner._setBloomEnabled(true);
|
||||
owner._setBloomIntensity(140.4);
|
||||
await nextTick();
|
||||
assert.equal($('bloom-toggle').classList.contains('active'), true);
|
||||
assert.equal($('bloom-slider-row').classList.contains('visible'), true);
|
||||
assert.equal($('bloom-intensity-slider').value, '140');
|
||||
assert.equal($('bloom-intensity-value').textContent, '140%');
|
||||
assert.equal(effects.bloomIntensity, 140);
|
||||
|
||||
owner._setSharpenEnabled(true);
|
||||
owner._setSharpenIntensity(130);
|
||||
await nextTick();
|
||||
assert.equal($('sharpen-toggle').classList.contains('active'), true);
|
||||
assert.equal($('sharpen-slider-row').classList.contains('visible'), true);
|
||||
assert.equal($('sharpen-intensity-slider').value, '100', 'clamped to 100');
|
||||
assert.equal($('sharpen-intensity-value').textContent, '100%');
|
||||
assert.equal(effects.sharpenIntensity, 1);
|
||||
assert.equal(owner.sharpenIntensityPct, 100);
|
||||
assert.ok(shares.length > 0, 'changes reach the share state');
|
||||
|
||||
owner._setBloomEnabled(false);
|
||||
await nextTick();
|
||||
assert.equal($('bloom-toggle').classList.contains('active'), false);
|
||||
assert.equal($('bloom-slider-row').classList.contains('visible'), false);
|
||||
assert.equal($('bloom-intensity-value').textContent, '140%', 'the amount stays');
|
||||
});
|
||||
|
||||
test('a publish never rewrites a slider the user is dragging', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner } = settings(store);
|
||||
owner._setSharpenIntensity(30);
|
||||
await nextTick();
|
||||
const slider = $('sharpen-intensity-slider');
|
||||
let writes = 0;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
Object.getPrototypeOf(slider),
|
||||
'value',
|
||||
);
|
||||
Object.defineProperty(slider, 'value', {
|
||||
get: () => descriptor.get.call(slider),
|
||||
set: (next) => {
|
||||
writes++;
|
||||
descriptor.set.call(slider, next);
|
||||
},
|
||||
});
|
||||
// The input handler applies the dragged value; the publish that follows
|
||||
// carries the same value, so the slider is left alone.
|
||||
owner._setSharpenIntensity(30);
|
||||
owner._setBloomEnabled(true);
|
||||
await nextTick();
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(slider.value, '30');
|
||||
});
|
||||
|
||||
/** VisualSettings with a stand-in detection engine. */
|
||||
function detectionSettings(store) {
|
||||
const { owner } = settings(store);
|
||||
const engine = {
|
||||
mode: 'OFF',
|
||||
tuning: { densityPct: 50, allocationStrategy: 'ELASTIC' },
|
||||
fade: { fadeRatio: 0.07, outsideOpacity: 0.01 },
|
||||
};
|
||||
Object.assign(owner, {
|
||||
_detectionDensityPct: 50,
|
||||
_detectionFadePct: 7,
|
||||
_detectionOutsidePct: 1,
|
||||
_detectionModeLabel: null,
|
||||
_detectionAllocationShown: null,
|
||||
viewer: { scene: { requestRender() {} } },
|
||||
});
|
||||
owner.services = {
|
||||
...owner.services,
|
||||
getDetectionMode: () => engine.mode,
|
||||
setDetectionModeByLabel: (label) => {
|
||||
engine.mode = label;
|
||||
},
|
||||
getDetectionTuning: () => ({ ...engine.tuning }),
|
||||
setDetectionTuning: (next) => Object.assign(engine.tuning, next),
|
||||
getKeyholeFadeTuning: () => ({ ...engine.fade }),
|
||||
setKeyholeFadeTuning: (next) => Object.assign(engine.fade, next),
|
||||
};
|
||||
return { owner, engine };
|
||||
}
|
||||
|
||||
test('detection renders its mode, rows, sliders and allocation', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner, engine } = detectionSettings(store);
|
||||
const button = $('detection-toggle');
|
||||
assert.equal(button.querySelector('.pp-label').textContent, 'DETECT');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Detection overlay');
|
||||
|
||||
owner._setDetectionMode('DENSE');
|
||||
owner._setDetectionDensity(80);
|
||||
await nextTick();
|
||||
assert.equal(button.querySelector('.pp-label').textContent, 'DENSE');
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Detection overlay: dense');
|
||||
assert.equal(coreClasses(button), 'pp-toggle-btn active panoptic');
|
||||
for (const id of [
|
||||
'detection-slider-row',
|
||||
'detection-allocation-row',
|
||||
'detection-fade-row',
|
||||
'detection-opacity-row',
|
||||
])
|
||||
assert.equal($(id).classList.contains('visible'), true, id);
|
||||
assert.equal($('detection-density-slider').value, '75', 'nearest stop');
|
||||
assert.equal($('detection-density-value').textContent, '75%');
|
||||
assert.equal(engine.tuning.densityPct, 75);
|
||||
|
||||
owner._setDetectionFade({ fadePct: 55 });
|
||||
owner._setDetectionAllocation('weighted', { persist: false });
|
||||
await nextTick();
|
||||
assert.equal($('detection-fade-slider').value, '40', 'clamped to 40');
|
||||
assert.equal($('detection-fade-value').textContent, '40%');
|
||||
assert.equal($('detection-opacity-value').textContent, '1%', 'kept');
|
||||
assert.equal(engine.fade.fadeRatio, 0.4);
|
||||
const weighted = $('detection-allocation-weighted');
|
||||
const elastic = $('detection-allocation-elastic');
|
||||
assert.equal(weighted.classList.contains('active'), true);
|
||||
assert.equal(weighted.getAttribute('aria-checked'), 'true');
|
||||
assert.equal(elastic.classList.contains('active'), false);
|
||||
assert.equal(elastic.getAttribute('aria-checked'), 'false');
|
||||
assert.deepEqual(owner.getDetectionState(), {
|
||||
detectionMode: 'DENSE',
|
||||
densityPct: 75,
|
||||
allocationStrategy: 'WEIGHTED',
|
||||
fadePct: 40,
|
||||
outsideOpacityPct: 1,
|
||||
});
|
||||
|
||||
owner._setDetectionMode('OFF');
|
||||
await nextTick();
|
||||
assert.equal(button.querySelector('.pp-label').textContent, 'DETECT');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Detection overlay: off');
|
||||
assert.equal(coreClasses(button), 'pp-toggle-btn');
|
||||
assert.equal($('detection-slider-row').classList.contains('visible'), false);
|
||||
});
|
||||
|
||||
test('the HUD and Sonar controls render what VisualSettings shows', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner } = settings(store);
|
||||
let visible = true;
|
||||
let variant = 'minimal';
|
||||
owner.readHud = () => ({
|
||||
get visible() {
|
||||
return visible;
|
||||
},
|
||||
getVariant: () => variant,
|
||||
});
|
||||
owner._scheduleAdaptivePanelLayout = () => {};
|
||||
assert.equal($('cyber-sonar-toggle').textContent, 'ON');
|
||||
assert.equal($('cyber-sonar-toggle').getAttribute('aria-pressed'), 'true');
|
||||
|
||||
owner._syncHudControls();
|
||||
await nextTick();
|
||||
assert.equal($('hud-toggle').classList.contains('active'), true);
|
||||
assert.equal($('hud-layout-row').classList.contains('visible'), true);
|
||||
assert.equal($('hud-layout-select').value, 'minimal');
|
||||
|
||||
visible = false;
|
||||
owner._updateHudButtonState();
|
||||
await nextTick();
|
||||
assert.equal($('hud-toggle').classList.contains('active'), false);
|
||||
assert.equal($('hud-layout-row').classList.contains('visible'), false);
|
||||
|
||||
owner._sonarShown = {
|
||||
enabled: false,
|
||||
rings: 5,
|
||||
range: 90,
|
||||
intensity: 40,
|
||||
opacity: 60,
|
||||
sector: 30,
|
||||
};
|
||||
owner._publishDisplay();
|
||||
await nextTick();
|
||||
const toggle = $('cyber-sonar-toggle');
|
||||
assert.equal(toggle.textContent, 'OFF');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(toggle.classList.contains('active'), false);
|
||||
assert.equal($('cyber-sonar-rings').value, '5');
|
||||
assert.equal($('cyber-sonar-rings-value').textContent, '5');
|
||||
assert.equal($('cyber-sonar-range-value').textContent, '90%');
|
||||
assert.equal($('cyber-sonar-sector').value, '30');
|
||||
assert.equal($('cyber-sonar-sector-value').textContent, '30°');
|
||||
});
|
||||
|
||||
test('Scope and Celestial render what VisualSettings shows', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner } = settings(store);
|
||||
const mask = { enabled: true, feather: 0.35 };
|
||||
owner.services = {
|
||||
...owner.services,
|
||||
setScopeMaskEnabled: (on) => (mask.enabled = on),
|
||||
setScopeMaskFeather: (value) => (mask.feather = value),
|
||||
};
|
||||
assert.equal($('scope-toggle').checked, true);
|
||||
assert.equal($('scope-feather-value').textContent, '100%');
|
||||
assert.equal($('celestial-toggle').hasAttribute('aria-disabled'), false);
|
||||
|
||||
owner._setScope({ enabled: false }, { apply: false });
|
||||
await nextTick();
|
||||
assert.equal($('scope-toggle').checked, false);
|
||||
assert.equal($('scope-feather-slider').disabled, true);
|
||||
assert.equal(mask.enabled, true, 'only shown');
|
||||
|
||||
owner._setScope({ enabled: true, featherPct: 250 });
|
||||
await nextTick();
|
||||
assert.equal($('scope-toggle').checked, true);
|
||||
assert.equal($('scope-feather-slider').disabled, false);
|
||||
assert.equal($('scope-feather-slider').value, '200', 'clamped');
|
||||
assert.equal($('scope-feather-value').textContent, '200%');
|
||||
assert.deepEqual(mask, { enabled: true, feather: 2 });
|
||||
|
||||
owner._celestialShown = { active: false, supported: false };
|
||||
owner._publishDisplay();
|
||||
await nextTick();
|
||||
const celestial = $('celestial-toggle');
|
||||
assert.equal(celestial.disabled, true);
|
||||
assert.equal(celestial.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(celestial.title, 'Celestial ring — available in Normal style');
|
||||
|
||||
owner._celestialShown = { active: true, supported: true };
|
||||
owner._publishDisplay();
|
||||
await nextTick();
|
||||
assert.equal(celestial.disabled, false);
|
||||
assert.equal(celestial.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(celestial.classList.contains('active'), true);
|
||||
assert.equal(celestial.title, 'Celestial ring — reveal the full globe');
|
||||
});
|
||||
|
||||
test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const params = [];
|
||||
const owner = new AircraftDisplay({
|
||||
elements: { _models3dBtn: $('models3d-toggle') },
|
||||
readDataManager: () => ({
|
||||
setLayerParams: (id, next) => params.push([id, next]),
|
||||
}),
|
||||
layout() {},
|
||||
store,
|
||||
});
|
||||
const button = $('models3d-toggle');
|
||||
const row = $('models3d-mode-row');
|
||||
const all = $('models3d-mode-all');
|
||||
assert.equal(coreClasses(button), 'pp-toggle-btn active');
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(row.classList.contains('visible'), true);
|
||||
assert.equal($('models3d-mode-proximity').getAttribute('aria-checked'), 'true');
|
||||
|
||||
// The button leaves the row to its own sync.
|
||||
owner._setModels3dEnabled(false);
|
||||
await nextTick();
|
||||
assert.equal(coreClasses(button), 'pp-toggle-btn');
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(row.classList.contains('visible'), true);
|
||||
owner._syncModels3dModeRow();
|
||||
await nextTick();
|
||||
assert.equal(row.classList.contains('visible'), false);
|
||||
|
||||
owner._setModels3dMode('all');
|
||||
await nextTick();
|
||||
assert.equal(all.classList.contains('active'), true);
|
||||
assert.equal(all.getAttribute('aria-checked'), 'true');
|
||||
assert.equal($('models3d-mode-proximity').classList.contains('active'), false);
|
||||
assert.deepEqual(params.at(-1), ['military', { models3dMode: 'all' }]);
|
||||
|
||||
owner._syncModels3dFromLayerState({
|
||||
options: { flights: { models3d: true, models3dMode: 'proximity' } },
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(row.classList.contains('visible'), true);
|
||||
assert.equal(all.getAttribute('aria-checked'), 'false');
|
||||
});
|
||||
|
||||
test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const toggle = $('draw-toggle');
|
||||
const segment = (shape) =>
|
||||
document.querySelector(`#draw-mode-row [data-shape="${shape}"]`);
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(segment('area').getAttribute('aria-checked'), 'true');
|
||||
assert.equal($('draw-hint').textContent, '');
|
||||
|
||||
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
|
||||
await nextTick();
|
||||
assert.equal(coreClasses(toggle), 'pp-toggle-btn active');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal($('draw-mode-row').classList.contains('visible'), true);
|
||||
assert.equal($('draw-label-row').classList.contains('visible'), true);
|
||||
assert.equal(segment('line').getAttribute('aria-checked'), 'true');
|
||||
assert.equal(segment('line').classList.contains('active'), true);
|
||||
assert.equal(segment('area').classList.contains('active'), false);
|
||||
assert.equal($('draw-hint').textContent, 'Click to add points.');
|
||||
|
||||
store.publishDraw({ active: false, shape: 'line', hint: '' });
|
||||
await nextTick();
|
||||
assert.equal(coreClasses(toggle), 'pp-toggle-btn');
|
||||
assert.equal($('draw-mode-row').classList.contains('visible'), false);
|
||||
assert.equal($('draw-label-row').classList.contains('visible'), false);
|
||||
});
|
||||
|
||||
test('the imported files render what the file import publishes and keep focus', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const list = $('geo-import-list');
|
||||
assert.equal(list.className, 'geo-import-list');
|
||||
assert.equal(list.children.length, 0);
|
||||
assert.equal($('geo-import-hint').textContent, '');
|
||||
|
||||
const viewer = {
|
||||
container: document.createElement('div'),
|
||||
scene: { requestRender() {} },
|
||||
dataSources: { add: async (ds) => ds, remove: () => true },
|
||||
};
|
||||
const tool = initGeoFileImport({
|
||||
viewer,
|
||||
load: async (file) => ({
|
||||
dataSource: { show: true },
|
||||
name: file.name,
|
||||
format: file.name.endsWith('.kml') ? 'kml' : 'geojson',
|
||||
entityCount: 2,
|
||||
removed: 0,
|
||||
revoke() {},
|
||||
}),
|
||||
flyToSource() {},
|
||||
publish: (view) => store.publishGeoImport(view),
|
||||
connect: (actions) => {
|
||||
const token = store.connect(actions);
|
||||
return () => store.detach(token);
|
||||
},
|
||||
});
|
||||
t.after(() => tool.destroy());
|
||||
await tool.importFiles([{ name: '<b>a</b>.geojson' }, { name: 'b.kml' }]);
|
||||
await nextTick();
|
||||
assert.equal(list.className, 'geo-import-list visible');
|
||||
assert.equal($('geo-import-hint').textContent, 'Added 2 files.');
|
||||
const rowsNow = () => [...list.querySelectorAll('.geo-import-row')];
|
||||
const [first, second] = rowsNow();
|
||||
assert.equal(first.children.length, 5);
|
||||
assert.equal(
|
||||
[...first.childNodes].some((node) => node.nodeType === 3 && node.data),
|
||||
false,
|
||||
'no text between the parts',
|
||||
);
|
||||
const [swatch, name, toggle] = first.children;
|
||||
assert.equal(swatch.className, 'geo-import-swatch');
|
||||
assert.ok(swatch.style.background);
|
||||
assert.equal(name.textContent, '<b>a</b>.geojson');
|
||||
assert.equal(name.children.length, 0, 'the name is text');
|
||||
assert.match(name.title, /GEOJSON, 2 map objects$/);
|
||||
assert.equal(toggle.className, 'geo-import-action');
|
||||
assert.equal(toggle.getAttribute('type'), 'button');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(toggle.getAttribute('aria-label'), 'Hide <b>a</b>.geojson');
|
||||
assert.equal(toggle.textContent, 'visibility');
|
||||
assert.equal(first.children[3].hasAttribute('aria-pressed'), false);
|
||||
assert.equal(second.children[0].className, 'geo-import-swatch own-styles');
|
||||
assert.equal(second.children[0].getAttribute('style'), null);
|
||||
|
||||
const button = (row, action) =>
|
||||
row.querySelector(`button[data-action="${action}"]`);
|
||||
// The click lands on the icon inside the button, as in a browser.
|
||||
const press = async (el) => {
|
||||
el.focus();
|
||||
el.firstElementChild.click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
};
|
||||
await press(button(first, 'toggle'));
|
||||
assert.equal(rowsNow()[0] === first, true, 'the row is kept');
|
||||
assert.equal(document.activeElement === button(first, 'toggle'), true);
|
||||
assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(button(first, 'toggle').textContent, 'visibility_off');
|
||||
assert.equal(button(first, 'toggle').title, 'Show <b>a</b>.geojson');
|
||||
await press(button(first, 'toggle'));
|
||||
assert.equal(document.activeElement === button(first, 'toggle'), true);
|
||||
assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(button(first, 'fly').title, 'Fly to <b>a</b>.geojson');
|
||||
assert.equal(button(first, 'remove').getAttribute('aria-label'), 'Remove <b>a</b>.geojson');
|
||||
|
||||
await press(button(first, 'remove'));
|
||||
assert.equal(rowsNow().length, 1);
|
||||
assert.equal(document.activeElement === button(second, 'remove'), true);
|
||||
await press(button(second, 'remove'));
|
||||
assert.equal(list.className, 'geo-import-list');
|
||||
assert.equal(document.activeElement === $('geo-import-button'), true);
|
||||
});
|
||||
|
||||
test('the parameters panel shows the active style’s rows and hands each change to its owner', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const panel = $('param-slider-panel');
|
||||
assert.equal(panel.classList.contains('active'), false);
|
||||
assert.equal($('param-sliders').children.length, 0);
|
||||
const changes = [];
|
||||
store.connect({
|
||||
setStyleParameter: (name, value) => changes.push([name, value]),
|
||||
});
|
||||
store.publishParameters({
|
||||
style: 'noir',
|
||||
rows: [
|
||||
{
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: '0.01',
|
||||
digits: 2,
|
||||
value: 0.25,
|
||||
text: '0.25',
|
||||
},
|
||||
],
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(panel.classList.contains('active'), true);
|
||||
const row = $('param-sliders').querySelector('.param-slider-row');
|
||||
assert.equal(row.querySelector('.param-label').textContent, 'Amount');
|
||||
assert.equal(row.querySelector('.param-value').textContent, '0.25');
|
||||
const slider = row.querySelector('input.param-slider');
|
||||
assert.equal(slider.getAttribute('aria-label'), 'Amount');
|
||||
assert.equal(slider.step, '0.01');
|
||||
slider.value = '0.5';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
assert.deepEqual(changes, [['amount', 0.5]]);
|
||||
store.publishParameters(null);
|
||||
await nextTick();
|
||||
assert.equal(panel.classList.contains('active'), false);
|
||||
assert.equal($('param-sliders').children.length, 0);
|
||||
});
|
||||
|
||||
test('VisualSettings publishes a style’s parameters and writes a moved slider’s uniform', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const owner = Object.create(VisualSettings.prototype);
|
||||
const renders = [];
|
||||
let shared = 0;
|
||||
Object.assign(owner, {
|
||||
store,
|
||||
_visualEffects: {
|
||||
stages: {
|
||||
noir: {
|
||||
uniforms: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 },
|
||||
},
|
||||
},
|
||||
stop() {},
|
||||
},
|
||||
services: { governorRequestRender: (reason) => renders.push(reason) },
|
||||
_parameters: null,
|
||||
readShareLinks: () => null,
|
||||
_scheduleRightPanelLayout() {},
|
||||
_syncShareState: () => (shared += 1),
|
||||
});
|
||||
owner._parametersToken = store.connect({
|
||||
setStyleParameter: (name, value) => owner.setStyleParameter(name, value),
|
||||
});
|
||||
owner._updateSliderPanel('noir');
|
||||
await nextTick();
|
||||
const labels = () =>
|
||||
[...$('param-sliders').querySelectorAll('.param-label')].map(
|
||||
(label) => label.textContent,
|
||||
);
|
||||
assert.deepEqual(labels(), ['Contrast', 'Grain', 'Vignette']);
|
||||
const grain = $('param-sliders').querySelectorAll('input.param-slider')[1];
|
||||
grain.value = '0.8';
|
||||
grain.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal(owner.stages.noir.uniforms.grainAmt, 0.8);
|
||||
assert.equal(
|
||||
$('param-sliders').querySelectorAll('.param-value')[1].textContent,
|
||||
'0.80',
|
||||
);
|
||||
assert.deepEqual(renders, ['style-param-slider']);
|
||||
assert.equal(shared, 1);
|
||||
// A name that isn't one of the shown style's parameters writes nothing.
|
||||
owner.setStyleParameter('pixelation', 4);
|
||||
assert.equal(owner.stages.noir.uniforms.pixelation, undefined);
|
||||
assert.equal(shared, 1);
|
||||
owner._updateSliderPanel('normal');
|
||||
await nextTick();
|
||||
assert.deepEqual(labels(), []);
|
||||
assert.equal($('param-slider-panel').classList.contains('active'), false);
|
||||
// A preset that resets the shown style's uniforms republishes its rows.
|
||||
owner._updateSliderPanel('noir');
|
||||
owner.stages.noir.uniforms.grainAmt = 0.3;
|
||||
owner._refreshParameters();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
$('param-sliders').querySelectorAll('.param-value')[1].textContent,
|
||||
'0.30',
|
||||
);
|
||||
owner._lifetime = { destroy() {} };
|
||||
owner.stop();
|
||||
await nextTick();
|
||||
assert.equal(store.parameters, null, 'the sliders go with their owner');
|
||||
assert.equal($('param-slider-panel').classList.contains('active'), false);
|
||||
assert.equal(store.act('setStyleParameter', 'grainAmt', 0.2), undefined);
|
||||
assert.equal(owner.stages.noir.uniforms.grainAmt, 0.3, 'a stopped owner no longer answers');
|
||||
// An older owner's stop leaves a newer owner's rows.
|
||||
const newer = { style: 'noir', rows: [] };
|
||||
owner._parameters = { style: 'noir', rows: [] };
|
||||
store.publishParameters(newer);
|
||||
owner.stop();
|
||||
assert.equal(store.parameters, newer);
|
||||
});
|
||||
|
||||
test('the four Cockpit groups teleport to the published slots and back to their places', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const groupOf = (id) => $(id).closest('.pp-toggle-group') ?? $(id);
|
||||
const ids = ['hud-toggle', 'detection-toggle', 'param-slider-panel', 'models3d-toggle'];
|
||||
const body = $('pp-toggles').querySelector('.pp-panel-body');
|
||||
const order = () =>
|
||||
[...body.children].filter((el) => el.matches('.pp-toggle-group, #param-slider-panel'))
|
||||
.map((el) => el.id || el.querySelector('[id]')?.id);
|
||||
const home = order();
|
||||
const popover = document.body.appendChild(document.createElement('div'));
|
||||
const slots = {};
|
||||
for (const name of ['hud', 'detection', 'parameters', 'models3d']) {
|
||||
slots[name] = popover.appendChild(document.createElement('div'));
|
||||
}
|
||||
store.publishCockpitPortal(slots);
|
||||
await nextTick();
|
||||
assert.equal(slots.hud.contains($('hud-toggle')), true);
|
||||
assert.equal(slots.detection.contains($('detection-toggle')), true);
|
||||
assert.equal(slots.parameters.firstElementChild, $('param-slider-panel'));
|
||||
assert.equal(slots.models3d.contains($('models3d-toggle')), true);
|
||||
// The teleported controls still render from the store.
|
||||
store.publishParameters({ style: 'noir', rows: [] });
|
||||
await nextTick();
|
||||
assert.equal($('param-slider-panel').classList.contains('active'), true);
|
||||
store.publishCockpitPortal(null);
|
||||
await nextTick();
|
||||
for (const id of ids) assert.equal(body.contains(groupOf(id)), true, id);
|
||||
assert.deepEqual(order(), home);
|
||||
// A second trip uses the same slots.
|
||||
store.publishCockpitPortal(slots);
|
||||
await nextTick();
|
||||
assert.equal(slots.hud.contains($('hud-toggle')), true);
|
||||
store.publishCockpitPortal(null);
|
||||
await nextTick();
|
||||
assert.deepEqual(order(), home);
|
||||
});
|
||||
|
||||
test('a group without a published slot stays home; a replaced slot and an unmount leave nothing behind', async (t) => {
|
||||
const { store, $, unmount } = fixture(t);
|
||||
const body = $('pp-toggles').querySelector('.pp-panel-body');
|
||||
const popover = document.body.appendChild(document.createElement('div'));
|
||||
const slot = () => popover.appendChild(document.createElement('div'));
|
||||
const first = { hud: slot(), detection: slot(), parameters: slot() };
|
||||
store.publishCockpitPortal(first);
|
||||
await nextTick();
|
||||
// No models3d slot: 3D stays in the Display panel, not in body.
|
||||
assert.equal(body.contains($('models3d-toggle')), true);
|
||||
assert.equal(first.hud.contains($('hud-toggle')), true);
|
||||
// A new popover replaces the slots while the groups are out.
|
||||
const second = { ...first, hud: slot() };
|
||||
store.publishCockpitPortal(second);
|
||||
await nextTick();
|
||||
assert.equal(second.hud.contains($('hud-toggle')), true);
|
||||
assert.equal(first.hud.childElementCount, 0);
|
||||
// Unmounting while teleported removes the groups from the slots too.
|
||||
unmount();
|
||||
for (const target of Object.values(second))
|
||||
assert.equal(target.childElementCount, 0);
|
||||
});
|
||||
|
||||
test('the real portal lays out once the groups have moved and puts back the focus the move dropped', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const popover = document.body.appendChild(document.createElement('div'));
|
||||
for (const name of ['hud', 'detection', 'parameters', 'models3d']) {
|
||||
const slot = popover.appendChild(document.createElement('div'));
|
||||
slot.dataset.cockpitDisplaySlot = name;
|
||||
}
|
||||
const frames = [];
|
||||
const priorFrame = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = priorFrame;
|
||||
});
|
||||
const layouts = [];
|
||||
const portal = new CockpitDisplayPortal({
|
||||
standardPanel: $('pp-toggles'),
|
||||
cockpitPanel: popover,
|
||||
groups: [
|
||||
['hud', $('hud-toggle').closest('.pp-toggle-group')],
|
||||
['detection', $('detection-toggle').closest('.pp-toggle-group')],
|
||||
['parameters', $('param-slider-panel')],
|
||||
['models3d', $('models3d-toggle').closest('.pp-toggle-group')],
|
||||
],
|
||||
layout: () => layouts.push(popover.contains($('hud-toggle'))),
|
||||
publish: (slots) => store.publishCockpitPortal(slots),
|
||||
afterRender: (callback) => {
|
||||
void nextTick(callback);
|
||||
},
|
||||
});
|
||||
t.after(() => portal.destroy());
|
||||
$('hud-toggle').focus();
|
||||
portal.setActive(true);
|
||||
// Superseded before the flush: one layout, for the last state.
|
||||
portal.setActive(false);
|
||||
portal.setActive(true);
|
||||
await nextTick();
|
||||
assert.deepEqual(layouts, [true]);
|
||||
while (frames.length) frames.shift()();
|
||||
assert.equal(document.activeElement, $('hud-toggle'));
|
||||
// A stopped portal lays out nothing.
|
||||
portal.stop();
|
||||
portal.setActive(false);
|
||||
await nextTick();
|
||||
assert.deepEqual(layouts, [true]);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
// src/ui/displayPanelActions.test.mjs — the Display panel's controls, the
|
||||
// clean-view exit and the dock's style buttons call the Display controls'
|
||||
// owner through the display store, and only while it is connected.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { DisplayBindings } from './displayBindings.js';
|
||||
import CommandDockView from './views/CommandDockView.vue';
|
||||
import DisplayControlsView from './views/DisplayControlsView.vue';
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useDisplayStore(pinia);
|
||||
const app = createApp({
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView)],
|
||||
}).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
return store;
|
||||
}
|
||||
const $ = (id) => document.getElementById(id);
|
||||
function recorder(store) {
|
||||
const calls = [];
|
||||
const actions = new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_, name) =>
|
||||
(...args) =>
|
||||
calls.push([name, ...args]),
|
||||
},
|
||||
);
|
||||
return { calls, token: store.connect(actions) };
|
||||
}
|
||||
function input(el, value, type = 'input') {
|
||||
el.value = String(value);
|
||||
el.dispatchEvent(new Event(type, { bubbles: true }));
|
||||
}
|
||||
|
||||
test('buttons, sliders, the layout select and the Scope checkbox call their actions', async (t) => {
|
||||
const store = mount(t);
|
||||
const { calls } = recorder(store);
|
||||
for (const id of [
|
||||
'hud-toggle',
|
||||
'cyber-sonar-toggle',
|
||||
'detection-toggle',
|
||||
'models3d-toggle',
|
||||
'celestial-toggle',
|
||||
'clean-view-toggle',
|
||||
'bloom-toggle',
|
||||
'sharpen-toggle',
|
||||
'clean-view-exit',
|
||||
])
|
||||
$(id).click();
|
||||
input($('hud-layout-select'), 'minimal', 'change');
|
||||
input($('cyber-sonar-rings'), 5);
|
||||
input($('cyber-sonar-range'), 90);
|
||||
input($('cyber-sonar-intensity'), 40);
|
||||
input($('cyber-sonar-opacity'), 60);
|
||||
input($('cyber-sonar-sector'), 30);
|
||||
input($('detection-density-slider'), 75);
|
||||
input($('detection-fade-slider'), 12);
|
||||
input($('detection-opacity-slider'), 3);
|
||||
input($('scope-feather-slider'), 150);
|
||||
input($('bloom-intensity-slider'), 24);
|
||||
input($('sharpen-intensity-slider'), 37);
|
||||
$('scope-toggle').checked = false;
|
||||
$('scope-toggle').dispatchEvent(new Event('change', { bubbles: true }));
|
||||
assert.deepEqual(calls, [
|
||||
['toggleHud'],
|
||||
['toggleSonar'],
|
||||
['cycleDetection'],
|
||||
['toggleModels'],
|
||||
['toggleCelestial'],
|
||||
['toggleCleanView'],
|
||||
['toggleBloom'],
|
||||
['toggleSharpen'],
|
||||
['exitCleanView'],
|
||||
['setHudLayout', 'minimal'],
|
||||
['setSonarRings', 5],
|
||||
['setSonarRange', 90],
|
||||
['setSonarIntensity', 40],
|
||||
['setSonarOpacity', 60],
|
||||
['setSonarSector', 30],
|
||||
// Density passes the slider's own value, as it always has.
|
||||
['setDensity', 75],
|
||||
['setFade', 12],
|
||||
['setOutsideOpacity', 3],
|
||||
['setScopeFeather', 150],
|
||||
['setBloomIntensity', 24],
|
||||
['setSharpenIntensity', 37],
|
||||
['setScopeEnabled', false],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the Draw controls call the draw tool and show what it publishes', async (t) => {
|
||||
const store = mount(t);
|
||||
const { calls } = recorder(store);
|
||||
$('draw-toggle').click();
|
||||
document.querySelector('[data-shape="pin"]').click();
|
||||
input($('draw-label-input'), 'Camp');
|
||||
input($('draw-color-select'), 'red', 'change');
|
||||
$('draw-clear').click();
|
||||
assert.deepEqual(calls, [
|
||||
['toggleDraw'],
|
||||
['setDrawShape', 'pin'],
|
||||
['setDrawLabel', 'Camp'],
|
||||
['setDrawColor', 'red'],
|
||||
['clearDraw'],
|
||||
]);
|
||||
store.publishDraw({
|
||||
active: true,
|
||||
shape: 'line',
|
||||
color: 'green',
|
||||
label: '',
|
||||
hint: 'Click to add points.',
|
||||
});
|
||||
await nextTick();
|
||||
const line = document.querySelector('[data-shape="line"]');
|
||||
assert.equal(line.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(line.classList.contains('active'));
|
||||
assert.equal($('draw-color-select').value, 'green');
|
||||
assert.equal($('draw-label-input').value, '', 'a finished shape empties it');
|
||||
assert.equal($('draw-hint').textContent, 'Click to add points.');
|
||||
assert.equal($('draw-toggle').getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
|
||||
test('style, allocation and model choices keep their ids and data attributes', async (t) => {
|
||||
const store = mount(t);
|
||||
const { calls } = recorder(store);
|
||||
document.querySelector('.style-btn[data-style="thermal"]').click();
|
||||
$('detection-allocation-weighted').click();
|
||||
$('models3d-mode-all').click();
|
||||
$('models3d-mode-proximity').click();
|
||||
assert.deepEqual(calls, [
|
||||
['setStyle', 'thermal'],
|
||||
['setAllocation', 'WEIGHTED'],
|
||||
['setModelsMode', 'all'],
|
||||
['setModelsMode', 'proximity'],
|
||||
]);
|
||||
assert.equal(
|
||||
$('detection-allocation-weighted').dataset.allocation,
|
||||
'WEIGHTED',
|
||||
);
|
||||
assert.equal($('models3d-mode-all').dataset.mode, 'all');
|
||||
// Their selection follows what the owners present.
|
||||
assert.equal(
|
||||
$('models3d-mode-proximity').getAttribute('aria-checked'),
|
||||
'true',
|
||||
);
|
||||
store.publishModels3d({ pressed: true, mode: 'all', rowVisible: true });
|
||||
await nextTick();
|
||||
assert.equal($('models3d-mode-all').getAttribute('aria-checked'), 'true');
|
||||
assert.ok($('models3d-mode-all').classList.contains('active'));
|
||||
});
|
||||
|
||||
test('a detached or replaced owner hears nothing', (t) => {
|
||||
const store = mount(t);
|
||||
const first = recorder(store);
|
||||
$('bloom-toggle').click();
|
||||
store.detach(first.token);
|
||||
$('bloom-toggle').click();
|
||||
const second = recorder(store);
|
||||
store.detach(first.token);
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(first.calls, [['toggleBloom']]);
|
||||
assert.deepEqual(second.calls, [['toggleBloom']]);
|
||||
});
|
||||
|
||||
test('DisplayBindings answers the controls from init until destroy', (t) => {
|
||||
const store = mount(t);
|
||||
const calls = [];
|
||||
const bindings = new DisplayBindings({
|
||||
viewer: null,
|
||||
services: { isScopeMaskEnabled: () => false, cycleDetectionMode: () => {} },
|
||||
elements: { _locationSearch: document.createElement('input') },
|
||||
operations: {
|
||||
_setScope: () => {},
|
||||
_setBloomEnabled: (enabled) => calls.push(['bloom', enabled]),
|
||||
},
|
||||
readState: () => ({ bloomEnabled: false }),
|
||||
claimDetection: () => {},
|
||||
displayStore: store,
|
||||
});
|
||||
bindings._initUI();
|
||||
// Initialising again replaces its own connection rather than stacking one.
|
||||
bindings._initUI();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
// A newer owner takes over; the old one's destroy leaves it connected.
|
||||
const next = recorder(store);
|
||||
bindings.destroy();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
assert.deepEqual(next.calls, [['toggleBloom']]);
|
||||
store.detach(next.token);
|
||||
bindings._initUI();
|
||||
bindings.destroy();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
});
|
||||
|
||||
test('several owners answer their own actions, the newest first', (t) => {
|
||||
const store = mount(t);
|
||||
const calls = [];
|
||||
const owner = (tag, names) =>
|
||||
store.connect(
|
||||
Object.fromEntries(
|
||||
names.map((name) => [name, () => calls.push([tag, name])]),
|
||||
),
|
||||
);
|
||||
const display = owner('display', ['toggleBloom', 'setStyle']);
|
||||
const draw = owner('draw', ['toggleDraw']);
|
||||
const newer = owner('newer', ['toggleBloom']);
|
||||
$('bloom-toggle').click();
|
||||
$('draw-toggle').click();
|
||||
store.detach(newer);
|
||||
$('bloom-toggle').click();
|
||||
store.detach(draw);
|
||||
$('draw-toggle').click();
|
||||
store.detach(display);
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [
|
||||
['newer', 'toggleBloom'],
|
||||
['draw', 'toggleDraw'],
|
||||
['display', 'toggleBloom'],
|
||||
]);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// src/ui/globeActions.test.mjs — the top-center globe actions call their
|
||||
// owners through the globe actions store, and a clear under way keeps its
|
||||
// button focusable and marks it busy.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useGlobeActionsStore } from './stores/globeActions.js';
|
||||
import GlobeActions from './views/panels/GlobeActions.vue';
|
||||
import CockpitView from './views/CockpitView.vue';
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useGlobeActionsStore(pinia);
|
||||
const calls = [];
|
||||
const token = store.connect({
|
||||
clear: () => calls.push('clear'),
|
||||
share: () => calls.push('share'),
|
||||
tilt: () => calls.push('tilt'),
|
||||
northUp: () => calls.push('northUp'),
|
||||
reset: () => calls.push('reset'),
|
||||
});
|
||||
const app = createApp(GlobeActions).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
return { calls, store, token };
|
||||
}
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
test('every button calls its owner', (t) => {
|
||||
const { calls } = mount(t);
|
||||
$('clear-selected-layers').click();
|
||||
$('share-btn').click();
|
||||
$('tilt-map-view').click();
|
||||
$('north-up-view').click();
|
||||
$('reset-globe-view').click();
|
||||
assert.deepEqual(calls, ['clear', 'share', 'tilt', 'northUp', 'reset']);
|
||||
});
|
||||
|
||||
test('the tilt and compass buttons show the published camera', async (t) => {
|
||||
const { store } = mount(t);
|
||||
const tilt = $('tilt-map-view');
|
||||
const north = $('north-up-view');
|
||||
assert.equal(tilt.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(tilt.getAttribute('aria-label'), 'Tilt map to oblique view');
|
||||
assert.equal(north.getAttribute('aria-label'), 'Reset map to north up');
|
||||
assert.equal(north.hasAttribute('data-north-up'), false);
|
||||
assert.equal(north.style.getPropertyValue('--camera-heading'), '');
|
||||
|
||||
store.publishCamera({ tilted: true, heading: 0 });
|
||||
await nextTick();
|
||||
assert.equal(tilt.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(
|
||||
tilt.getAttribute('aria-label'),
|
||||
'Return map to straight-down view',
|
||||
);
|
||||
assert.equal(north.getAttribute('data-north-up'), 'true');
|
||||
assert.equal(north.style.getPropertyValue('--camera-heading'), '0deg');
|
||||
|
||||
store.publishCamera({ tilted: false, heading: 47 });
|
||||
await nextTick();
|
||||
assert.equal(tilt.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(north.getAttribute('data-north-up'), 'false');
|
||||
assert.equal(north.style.getPropertyValue('--camera-heading'), '47deg');
|
||||
assert.equal(
|
||||
north.getAttribute('aria-label'),
|
||||
'Reset map to north up. Current heading 47 degrees',
|
||||
);
|
||||
|
||||
// A disposed shell hands the next one an unmeasured compass.
|
||||
store.publishCamera(null);
|
||||
await nextTick();
|
||||
assert.equal(north.hasAttribute('data-north-up'), false);
|
||||
assert.equal(north.style.getPropertyValue('--camera-heading'), '');
|
||||
});
|
||||
|
||||
test('a clear under way marks the button busy and keeps it focusable', async (t) => {
|
||||
const { store, token } = mount(t);
|
||||
const clear = $('clear-selected-layers');
|
||||
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
||||
assert.equal(clear.hasAttribute('aria-busy'), false);
|
||||
store.publishClearBusy(token, true);
|
||||
await nextTick();
|
||||
assert.equal(clear.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(clear.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(
|
||||
clear.getAttribute('aria-label'),
|
||||
'Clearing selected data layers',
|
||||
);
|
||||
assert.equal(clear.disabled, false);
|
||||
store.publishClearBusy(token, false);
|
||||
await nextTick();
|
||||
assert.equal(clear.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
||||
// The button shows the clear of the owner that answers it: a newer one's,
|
||||
// then the older one's again once the newer goes.
|
||||
store.publishClearBusy(token, true);
|
||||
const newer = store.connect({ clear() {} });
|
||||
await nextTick();
|
||||
assert.equal(clear.hasAttribute('aria-busy'), false);
|
||||
store.publishClearBusy(newer, false);
|
||||
await nextTick();
|
||||
assert.equal(clear.getAttribute('aria-busy'), 'false');
|
||||
store.detach(newer);
|
||||
await nextTick();
|
||||
assert.equal(clear.getAttribute('aria-busy'), 'true');
|
||||
// A shell disposed mid-clear hands the next one a bar that is not busy.
|
||||
store.detach(token);
|
||||
await nextTick();
|
||||
assert.equal(clear.hasAttribute('aria-busy'), false);
|
||||
assert.equal(clear.hasAttribute('aria-disabled'), false);
|
||||
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
||||
});
|
||||
|
||||
test('a detached owner no longer answers', (t) => {
|
||||
const { calls, store } = mount(t);
|
||||
const token = store.connect({ share: () => calls.push('newer share') });
|
||||
$('share-btn').click();
|
||||
store.detach(token);
|
||||
$('share-btn').click();
|
||||
assert.deepEqual(calls, ['newer share', 'share']);
|
||||
});
|
||||
|
||||
test('the same camera state again changes nothing', () => {
|
||||
const store = useGlobeActionsStore(createPinia());
|
||||
store.publishCamera({ tilted: true, heading: 12 });
|
||||
const kept = store.camera;
|
||||
store.publishCamera({ tilted: true, heading: 12 });
|
||||
assert.equal(store.camera, kept);
|
||||
store.publishCamera({ tilted: true, heading: 13 });
|
||||
assert.notEqual(store.camera, kept);
|
||||
assert.deepEqual(store.camera, { tilted: true, heading: 13 });
|
||||
});
|
||||
|
||||
test('a reset under way relabels the reset button for its owner', async (t) => {
|
||||
const { store, token } = mount(t);
|
||||
const reset = $('reset-globe-view');
|
||||
assert.equal(reset.getAttribute('aria-label'), 'Reset to full globe view');
|
||||
store.publishResetting(token, true);
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
reset.getAttribute('aria-label'),
|
||||
'Resetting to full globe view',
|
||||
);
|
||||
assert.equal(store.clearBusy, null, 'a reset is not a clear');
|
||||
store.detach(token);
|
||||
await nextTick();
|
||||
assert.equal(reset.getAttribute('aria-label'), 'Reset to full globe view');
|
||||
});
|
||||
|
||||
test('the cockpit reset asks the reset owner and shows its reset', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useGlobeActionsStore(pinia);
|
||||
const calls = [];
|
||||
const token = store.connect({ reset: () => calls.push('reset') });
|
||||
const app = createApp(CockpitView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const reset = $('cockpit-reset-globe');
|
||||
assert.equal(
|
||||
reset.getAttribute('aria-label'),
|
||||
'Reset cockpit to full globe view',
|
||||
);
|
||||
reset.click();
|
||||
assert.deepEqual(calls, ['reset']);
|
||||
store.publishResetting(token, true);
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
reset.getAttribute('aria-label'),
|
||||
'Resetting cockpit to full globe view',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { watch } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import {
|
||||
bindLocale,
|
||||
getCatalog,
|
||||
getLocale,
|
||||
notifyLocaleChange,
|
||||
} from '../i18n/index.js';
|
||||
import { DEFAULT_LOCALE, LOCALES } from '../i18n/locale.js';
|
||||
|
||||
let pageI18n = null;
|
||||
|
||||
// Catalogs are flat and keyed by English text, which holds dots.
|
||||
const resolveFlat = (messages, key) =>
|
||||
Object.hasOwn(messages, key) ? messages[key] : null;
|
||||
|
||||
/**
|
||||
* The page's vue-i18n instance, installed in every application view. It
|
||||
* holds the active locale and the catalogs; Core's t(), tn() and the Intl
|
||||
* formatters read the locale from it, so a view that calls them while
|
||||
* rendering re-renders when the locale changes. Views translate through
|
||||
* t()/tn() rather than vue-i18n's own t: English is the key, `context` picks
|
||||
* an entry, and plural forms follow Intl.PluralRules (docs/I18N.md).
|
||||
* @returns {import('vue-i18n').I18n}
|
||||
*/
|
||||
export function applicationI18n() {
|
||||
if (pageI18n) return pageI18n;
|
||||
const messages = {};
|
||||
for (const locale of LOCALES)
|
||||
if (locale !== DEFAULT_LOCALE) messages[locale] = getCatalog(locale);
|
||||
pageI18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
locale: getLocale(),
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages,
|
||||
messageResolver: resolveFlat,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
});
|
||||
// vue-i18n disposes its instance when an app it is installed in unmounts;
|
||||
// the page's outlives every view, so an unmounted panel keeps it.
|
||||
const install = pageI18n.install;
|
||||
pageI18n.install = (app, ...options) => {
|
||||
const unmount = app.unmount;
|
||||
const installed = install.call(pageI18n, app, ...options);
|
||||
app.unmount = unmount;
|
||||
return installed;
|
||||
};
|
||||
bindLocale(pageI18n.global.locale);
|
||||
// Code that translated text itself shows it again, whichever way the
|
||||
// locale changed (setLocale or the ref).
|
||||
watch(
|
||||
() => getLocale(),
|
||||
() => notifyLocaleChange(),
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
return pageI18n;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||
|
||||
let vue;
|
||||
let i18n;
|
||||
let applicationI18n;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
vue = await import('vue');
|
||||
i18n = await import('../i18n/index.js');
|
||||
({ applicationI18n } = await import('./i18n.js'));
|
||||
});
|
||||
after(() => {
|
||||
i18n.setLocale('en');
|
||||
uninstall();
|
||||
});
|
||||
|
||||
test('vue-i18n takes the locale set before it and holds the catalogs', () => {
|
||||
i18n.setLocale('ru');
|
||||
const { global } = applicationI18n();
|
||||
assert.equal(applicationI18n(), applicationI18n());
|
||||
assert.equal(global.locale.value, 'ru');
|
||||
assert.equal(global.te('Settings'), true);
|
||||
assert.equal(global.tm('Settings'), 'Настройки');
|
||||
global.locale.value = 'de';
|
||||
assert.equal(i18n.getLocale(), 'de');
|
||||
global.locale.value = 'ru_RU';
|
||||
assert.equal(i18n.getLocale(), 'ru');
|
||||
assert.equal(i18n.t('Settings'), 'Настройки');
|
||||
assert.equal(i18n.formatNumber(1234.5), '1\u00a0234,5');
|
||||
global.locale.value = 'xx';
|
||||
assert.equal(i18n.getLocale(), 'en');
|
||||
i18n.setLocale('en');
|
||||
assert.equal(global.locale.value, 'en');
|
||||
});
|
||||
|
||||
test('a view that translates re-renders when the locale changes', async () => {
|
||||
const { createApp, h, nextTick } = vue;
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
h('p', [
|
||||
i18n.t('Settings'),
|
||||
' / ',
|
||||
i18n.tn(
|
||||
3,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
),
|
||||
]),
|
||||
}).use(applicationI18n());
|
||||
app.mount(host);
|
||||
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
|
||||
i18n.setLocale('ru');
|
||||
await nextTick();
|
||||
assert.equal(host.textContent, 'Настройки / ЗАПУСК · 3 КЛЮЧА В ОЖИДАНИИ');
|
||||
i18n.setLocale('en');
|
||||
await nextTick();
|
||||
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
test('unmounting one view keeps the page instance for the others', async () => {
|
||||
const { createApp, h, nextTick } = vue;
|
||||
const page = applicationI18n();
|
||||
let disposed = 0;
|
||||
const dispose = page.dispose;
|
||||
page.dispose = () => {
|
||||
disposed += 1;
|
||||
dispose.call(page);
|
||||
};
|
||||
const mountView = () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
const app = createApp({ render: () => h('p', i18n.t('Settings')) });
|
||||
app.use(page).mount(host);
|
||||
return { app, host };
|
||||
};
|
||||
const first = mountView();
|
||||
const second = mountView();
|
||||
first.app.unmount();
|
||||
assert.equal(disposed, 0);
|
||||
i18n.setLocale('ru');
|
||||
await nextTick();
|
||||
assert.equal(second.host.textContent, 'Настройки');
|
||||
const third = mountView();
|
||||
assert.equal(third.host.textContent, 'Настройки');
|
||||
i18n.setLocale('en');
|
||||
second.app.unmount();
|
||||
third.app.unmount();
|
||||
page.dispose = dispose;
|
||||
});
|
||||
+92
-86
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// src/ui/imagerySplitView.test.mjs — the imagery dividers render from the
|
||||
// imagery split store as children of the body, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useImagerySplitStore } from './stores/imagerySplit.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
import ImagerySplitView from './views/panels/ImagerySplitView.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(ImagerySplitView).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useImagerySplitStore(pinia);
|
||||
const scene = { splitPosition: 0.37 };
|
||||
const changes = [];
|
||||
const split = (options = {}) =>
|
||||
createImagerySplit({
|
||||
scene,
|
||||
id: 'split-line',
|
||||
handleClass: 'split-handle',
|
||||
cssProperty: '--split',
|
||||
beforeTitle: 'Before',
|
||||
afterTitle: 'After',
|
||||
ariaLabel: 'Divider',
|
||||
getViewportWidth: () => 1000,
|
||||
onChange: (value) => changes.push(value),
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
return { split, scene, changes };
|
||||
}
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
|
||||
test('the divider renders on the body with its labels and slider', async (t) => {
|
||||
const { split } = fixture(t);
|
||||
split({ initialValue: 0.62 });
|
||||
await nextTick();
|
||||
const line = $('#split-line');
|
||||
assert.equal(line.parentElement, document.body);
|
||||
const [before, handle, after] = line.children;
|
||||
assert.equal(before.className, 'before');
|
||||
assert.equal(before.textContent, 'A');
|
||||
assert.equal(before.title, 'Before');
|
||||
assert.equal(after.className, 'after');
|
||||
assert.equal(after.textContent, 'B');
|
||||
assert.equal(after.title, 'After');
|
||||
assert.equal(handle.tagName, 'BUTTON');
|
||||
assert.equal(handle.type, 'button');
|
||||
assert.equal(handle.className, 'split-handle');
|
||||
assert.equal(handle.textContent, '↔');
|
||||
assert.equal(handle.getAttribute('role'), 'slider');
|
||||
assert.equal(handle.getAttribute('aria-label'), 'Divider');
|
||||
assert.equal(handle.getAttribute('aria-valuemin'), '0');
|
||||
assert.equal(handle.getAttribute('aria-valuemax'), '100');
|
||||
assert.equal(handle.getAttribute('aria-orientation'), 'horizontal');
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '62');
|
||||
assert.equal(
|
||||
handle.getAttribute('aria-valuetext'),
|
||||
'A 62 percent, B 38 percent',
|
||||
);
|
||||
assert.equal(
|
||||
document.documentElement.style.getPropertyValue('--split'),
|
||||
'62%',
|
||||
);
|
||||
});
|
||||
|
||||
test('the handle steps by keyboard and drags with the pointer it captured', async (t) => {
|
||||
const { split, scene, changes } = fixture(t);
|
||||
split();
|
||||
await nextTick();
|
||||
const handle = $('#split-line button');
|
||||
const captured = [];
|
||||
const released = [];
|
||||
handle.setPointerCapture = (id) => captured.push(id);
|
||||
handle.releasePointerCapture = (id) => released.push(id);
|
||||
handle.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal(scene.splitPosition, 0.51);
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '51');
|
||||
const pointer = (type, init) =>
|
||||
handle.dispatchEvent(
|
||||
new PointerEvent(type, { bubbles: true, pointerId: 7, ...init }),
|
||||
);
|
||||
pointer('pointerdown', { button: 0, clientX: 250 });
|
||||
pointer('pointermove', { clientX: 800 });
|
||||
pointer('pointercancel', {});
|
||||
pointer('pointermove', { clientX: 100 });
|
||||
await nextTick();
|
||||
assert.equal(scene.splitPosition, 0.8);
|
||||
assert.deepEqual(captured, [7]);
|
||||
assert.deepEqual(released, [7]);
|
||||
assert.deepEqual(changes, [0.51, 0.25, 0.8]);
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '80');
|
||||
});
|
||||
|
||||
test('hiding and destroying a divider leave other dividers in place', async (t) => {
|
||||
const { split } = fixture(t);
|
||||
const first = split();
|
||||
const second = split({ id: 'second-line' });
|
||||
await nextTick();
|
||||
first.setVisible(false);
|
||||
await nextTick();
|
||||
assert.equal($('#split-line').hidden, true);
|
||||
assert.equal($('#second-line').hidden, false);
|
||||
first.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#split-line'), null);
|
||||
assert.ok($('#second-line'));
|
||||
second.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#second-line'), null);
|
||||
});
|
||||
|
||||
test('a divider created before the view mounts renders once it does', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const split = createImagerySplit({
|
||||
id: 'early-line',
|
||||
getViewportWidth: () => 1000,
|
||||
store: useImagerySplitStore(pinia),
|
||||
});
|
||||
const app = createApp(ImagerySplitView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
await nextTick();
|
||||
assert.equal($('#early-line').parentElement, document.body);
|
||||
split.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#early-line'), null);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiBadge;
|
||||
let UiEmpty;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiBadge = (await import('./UiBadge.vue')).default;
|
||||
UiEmpty = (await import('./UiEmpty.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
test('a badge carries the kit classes, Core’s and the caller’s', async () => {
|
||||
const wrapper = mount(UiBadge, {
|
||||
attrs: { id: 'cal', class: 'gev-status-badge--box', role: 'status' },
|
||||
slots: { default: 'CAL · --' },
|
||||
});
|
||||
const badge = wrapper.get('span');
|
||||
assert.deepEqual(badge.classes(), [
|
||||
'ui-badge',
|
||||
'gev-status-badge',
|
||||
'ui-badge--neutral',
|
||||
'gev-status-badge--box',
|
||||
]);
|
||||
assert.equal(badge.attributes('role'), 'status');
|
||||
assert.equal(badge.element.textContent, 'CAL · --');
|
||||
await wrapper.setProps({ tone: 'warn' });
|
||||
assert.ok(badge.classes().includes('ui-badge--warn'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('an empty state is its title, then its hint', async () => {
|
||||
const wrapper = mount(UiEmpty, {
|
||||
props: { title: 'No shots yet.' },
|
||||
attrs: { class: 'scene-shot-empty', hidden: true },
|
||||
});
|
||||
const empty = wrapper.get('div');
|
||||
assert.deepEqual(empty.classes(), ['ui-empty', 'scene-shot-empty']);
|
||||
assert.equal(empty.element.textContent, 'No shots yet.');
|
||||
assert.equal(empty.attributes('title'), undefined);
|
||||
assert.equal(empty.element.hidden, true);
|
||||
await wrapper.setProps({ hint: 'Capture one.' });
|
||||
assert.equal(empty.get('.ui-empty__hint').text(), 'Capture one.');
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
// Core's read-only badge, with a `tone` and the kit's classes. It carries Core's `gev-status-badge` class, and
|
||||
// callers add its density variants (`gev-status-badge--box`, `--plain`) and
|
||||
// the feature classes that colour it; the kit classes carry no Core styles
|
||||
// yet. Not mirrored yet: pulse, busy, the tone glyphs and the clipped title.
|
||||
defineProps({
|
||||
tone: { type: String, default: 'neutral' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ui-badge gev-status-badge" :class="`ui-badge--${tone}`"
|
||||
><slot
|
||||
/></span>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let vue;
|
||||
let renderToString;
|
||||
let UiButton;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
vue = await import('vue');
|
||||
({ renderToString } = await import('vue/server-renderer'));
|
||||
UiButton = (await import('./UiButton.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
function mountButton(props = {}, attrs = {}) {
|
||||
return mount(UiButton, {
|
||||
attachTo: document.body,
|
||||
props,
|
||||
attrs,
|
||||
slots: { default: 'Go' },
|
||||
});
|
||||
}
|
||||
|
||||
test('a pressed toggle carries aria-pressed, the kit class and Core’s', async () => {
|
||||
const wrapper = mountButton(
|
||||
{ pressed: false, pressedClass: 'active' },
|
||||
{ id: 'go', class: 'pp-toggle-btn' },
|
||||
);
|
||||
const button = wrapper.get('button');
|
||||
assert.equal(button.attributes('aria-pressed'), 'false');
|
||||
assert.equal(button.classes().includes('active'), false);
|
||||
await wrapper.setProps({ pressed: true });
|
||||
assert.equal(button.attributes('aria-pressed'), 'true');
|
||||
assert.ok(button.classes().includes('active'));
|
||||
assert.ok(button.classes().includes('ui-button--pressed'));
|
||||
assert.ok(button.classes().includes('pp-toggle-btn'));
|
||||
assert.equal(button.attributes('type'), 'button');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('no aria-pressed unless pressed is passed', () => {
|
||||
const wrapper = mountButton();
|
||||
assert.equal(wrapper.get('button').attributes('aria-pressed'), undefined);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a class other code adds survives a press', async () => {
|
||||
const wrapper = mountButton({ pressed: false, pressedClass: 'active' });
|
||||
const button = wrapper.get('button').element;
|
||||
button.classList.add('voice-highlight');
|
||||
await wrapper.setProps({ pressed: true });
|
||||
await wrapper.setProps({ pressed: false });
|
||||
assert.ok(button.classList.contains('voice-highlight'));
|
||||
assert.equal(button.classList.contains('active'), false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a caller’s class change keeps the pressed classes', async () => {
|
||||
const wrapper = mountButton(
|
||||
{ pressed: true, pressedClass: 'active' },
|
||||
{ class: { panoptic: false } },
|
||||
);
|
||||
await wrapper.setProps({ class: { panoptic: true } });
|
||||
const classes = wrapper.get('button').classes();
|
||||
assert.ok(classes.includes('panoptic'));
|
||||
assert.ok(classes.includes('active'));
|
||||
assert.ok(classes.includes('ui-button--pressed'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a renamed pressedClass leaves the button', async () => {
|
||||
const wrapper = mountButton({ pressed: true, pressedClass: 'active' });
|
||||
await wrapper.setProps({ pressedClass: 'selected' });
|
||||
let classes = wrapper.get('button').classes();
|
||||
assert.equal(classes.includes('active'), false);
|
||||
assert.ok(classes.includes('selected'));
|
||||
await wrapper.setProps({ pressed: false });
|
||||
classes = wrapper.get('button').classes();
|
||||
assert.equal(classes.includes('selected'), false);
|
||||
assert.equal(classes.includes('active'), false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('server-rendered markup carries the pressed classes', async () => {
|
||||
const { createSSRApp, h } = vue;
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
UiButton,
|
||||
{ id: 'fm', pressed: true, pressedClass: 'active' },
|
||||
() => 'FM',
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.match(html, /<button[^>]*class="[^"]*\bactive\b[^"]*"/);
|
||||
assert.match(html, /aria-pressed="true"/);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
|
||||
// Core's button and its attribute contract: the button is always the root,
|
||||
// attributes passed in win over its own (a `title` replaces the label's), and
|
||||
// `pressed` renders aria-pressed only when it is passed. Core styles the
|
||||
// button through the classes its callers pass; the kit classes carry no Core
|
||||
// styles yet. Core adds `pressedClass`, its class on a pressed button (most
|
||||
// toggles use `active`). The pressed classes toggle through `classList`, so
|
||||
// classes other code adds to the button (a voice highlight) survive a press.
|
||||
// Not mirrored yet: icon, loading, disabledReason and the missing-label
|
||||
// warning.
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'ghost' },
|
||||
tone: { type: String, default: 'accent' },
|
||||
/** The accessible name (aria-label) and, with nativeTitle, the tip. */
|
||||
label: { type: String, default: undefined },
|
||||
pressed: { type: Boolean, default: undefined },
|
||||
/** Core's class on the button while it is pressed. */
|
||||
pressedClass: { type: String, default: undefined },
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' },
|
||||
/** The id of the form a submit button belongs to, where it sits outside it. */
|
||||
form: { type: String, default: undefined },
|
||||
nativeTitle: { type: Boolean, default: true },
|
||||
});
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
// A pressedClass that changes keeps its old name, switched off, so the class
|
||||
// leaves the button with it.
|
||||
const pressedClasses = computed((previous) => {
|
||||
const on = Boolean(props.pressed);
|
||||
const classes = { 'ui-button--pressed': on };
|
||||
for (const name of Object.keys(previous || {}))
|
||||
if (name !== 'ui-button--pressed') classes[name] = false;
|
||||
if (props.pressedClass) classes[props.pressedClass] = on;
|
||||
return classes;
|
||||
});
|
||||
// Server-rendered markup carries the pressed classes in its class attribute.
|
||||
const vPressedClasses = {
|
||||
...vClassToggle,
|
||||
getSSRProps: ({ value }) => ({
|
||||
class: Object.keys(value).filter((name) => value[name]),
|
||||
}),
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="props.type"
|
||||
:form="props.form"
|
||||
class="ui-button ui-focus"
|
||||
:class="[`ui-button--${props.variant}`, `ui-button--${props.tone}`]"
|
||||
:aria-label="props.label"
|
||||
:title="props.nativeTitle ? props.label : undefined"
|
||||
:aria-pressed="
|
||||
props.pressed === undefined ? undefined : props.pressed ? 'true' : 'false'
|
||||
"
|
||||
:disabled="props.disabled"
|
||||
v-bind="$attrs"
|
||||
v-pressed-classes="pressedClasses"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiChip;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiChip = (await import('./UiChip.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
test('a chip is a span with the kit classes and Core’s', async () => {
|
||||
const wrapper = mount(UiChip, {
|
||||
attrs: { class: 'gev-voice-card-chip', 'data-tone': 'ok' },
|
||||
slots: { default: 'ok' },
|
||||
});
|
||||
const chip = wrapper.get('span');
|
||||
assert.deepEqual(chip.classes(), ['ui-chip', 'gev-voice-card-chip']);
|
||||
assert.equal(chip.attributes('data-tone'), 'ok');
|
||||
assert.equal(chip.text(), 'ok');
|
||||
await wrapper.setProps({ selected: true, mono: true });
|
||||
assert.ok(chip.classes().includes('ui-chip--selected'));
|
||||
assert.ok(chip.classes().includes('ui-mono'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
// Core's chip: a label that says what something is, never a control.
|
||||
// Core styles it through the classes its callers pass; the kit classes carry
|
||||
// no Core styles yet. A chip that presses is a UiButton with pressedClass.
|
||||
defineProps({
|
||||
selected: { type: Boolean, default: false },
|
||||
mono: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="ui-chip"
|
||||
:class="{ 'ui-chip--selected': selected, 'ui-mono': mono }"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
// Core's empty state, with a `title` and `hint`. Core's empty states are one
|
||||
// line, so the title is the text itself rather than a bold heading; a hint
|
||||
// follows it. Core
|
||||
// styles it through the classes its callers pass. Not mirrored yet: icon.
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
hint: { type: String, default: undefined },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-empty">
|
||||
{{ title }}<span v-if="hint" class="ui-empty__hint">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
/** A Material Symbols glyph; the name must be in index.html's `icon_names`. */
|
||||
defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: Number, default: 18 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="ui-icon material-symbols-outlined"
|
||||
:style="{ fontSize: `${size}px` }"
|
||||
aria-hidden="true"
|
||||
>{{ name }}</span
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import UiPanelToggle from './UiPanelToggle.vue';
|
||||
|
||||
// Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup
|
||||
// (glow, inner, a header with the title, badges, divider, actions and the
|
||||
// collapse button, then the body) and keeps Core's classes beside the kit's.
|
||||
// `collapsed` does not hide the body: the root's classes
|
||||
// stay as rendered, and whatever owns the panel's state writes its
|
||||
// `collapsed` class at once (PanelChrome, through vPanelState), because the
|
||||
// rails measure a panel in the task that opens it. `collapsed` drives the
|
||||
// button and the kit's `ui-panel--collapsed`, toggled without a re-render.
|
||||
// There is no Close: Core's panels collapse.
|
||||
const props = defineProps({
|
||||
/** The root's id, its inner's class (`{id}-inner`) and the button's target. */
|
||||
id: { type: String, required: true },
|
||||
/** The heading, already translated; also the button's "Expand {name}". */
|
||||
title: { type: String, required: true },
|
||||
/** The heading's tip. */
|
||||
hint: { type: String, default: undefined },
|
||||
tag: { type: String, default: 'div' },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
|
||||
sideways: { type: Boolean, default: false },
|
||||
/** Words that replace "Expand/Collapse {title}" on the button. */
|
||||
toggleWords: { type: Object, default: null },
|
||||
glow: { type: Boolean, default: true },
|
||||
/** The inner scrolls with Core's scroll bar. */
|
||||
scroll: { type: Boolean, default: false },
|
||||
/** The inner is a scroller the right rail keeps in view. */
|
||||
railScroller: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['update:collapsed']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.tag"
|
||||
:id="props.id"
|
||||
v-class-toggle="{
|
||||
'ui-panel--collapsed': props.collapsible && props.collapsed,
|
||||
}"
|
||||
class="ui-panel panel-collapsible"
|
||||
>
|
||||
<div v-if="props.glow" class="panel-glow"></div>
|
||||
<div
|
||||
:class="[`${props.id}-inner`, { 'ui-scroll': props.scroll }]"
|
||||
:data-rail-scroller="props.railScroller ? '' : undefined"
|
||||
>
|
||||
<div class="ui-panel__head panel-header">
|
||||
<slot name="title">
|
||||
<span class="ui-panel__title panel-title" :title="props.hint">{{
|
||||
props.title
|
||||
}}</span>
|
||||
</slot>
|
||||
<slot name="badge" />
|
||||
<span class="panel-divider"></span>
|
||||
<slot name="actions" />
|
||||
<UiPanelToggle
|
||||
v-if="props.collapsible"
|
||||
:target="props.id"
|
||||
:name="props.title"
|
||||
:collapsed="props.collapsed"
|
||||
:sideways="props.sideways"
|
||||
:words="props.toggleWords"
|
||||
@update:collapsed="emit('update:collapsed', $event)"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import UiButton from './UiButton.vue';
|
||||
|
||||
// A panel's collapse button, internal to the kit: UiPanel draws it, and so
|
||||
// does PanelCollapseButton for the panels not built on UiPanel. Its glyph and
|
||||
// words follow `collapsed`; a click asks for the other state.
|
||||
const props = defineProps({
|
||||
/** The panel's id, which PanelChrome finds the button by. */
|
||||
target: { type: String, required: true },
|
||||
/** The panel's name in "Expand {name}", already translated; "panel" when unset. */
|
||||
name: { type: String, default: null },
|
||||
collapsed: { type: Boolean, required: true },
|
||||
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
|
||||
sideways: { type: Boolean, default: false },
|
||||
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
|
||||
words: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(['update:collapsed']);
|
||||
|
||||
const glyph = computed(() => {
|
||||
if (props.sideways) return props.collapsed ? '◀' : '▶';
|
||||
return props.collapsed ? '+' : '−';
|
||||
});
|
||||
const label = computed(() => {
|
||||
if (props.words)
|
||||
return props.collapsed
|
||||
? props.words.expandLabel
|
||||
: props.words.collapseLabel;
|
||||
const key = props.collapsed ? msg('Expand {name}') : msg('Collapse {name}');
|
||||
return t(key, { name: props.name ?? t('panel') });
|
||||
});
|
||||
const title = computed(() => {
|
||||
if (props.words)
|
||||
return props.collapsed
|
||||
? props.words.expandTitle
|
||||
: props.words.collapseTitle;
|
||||
return label.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiButton
|
||||
variant="icon"
|
||||
class="panel-collapse-btn"
|
||||
:data-collapse-target="target"
|
||||
:label="label"
|
||||
:title="title"
|
||||
:aria-expanded="String(!collapsed)"
|
||||
@click="emit('update:collapsed', !collapsed)"
|
||||
>{{ glyph }}</UiButton
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user