feat(globe): answer the clear and share actions through the globe actions store

The top-center bar is now GlobeActions.vue. The shell publishes whether a
clear is under way and answers the clear and share buttons through the
globe actions store, instead of binding listeners and writing attributes on
the buttons. A shell disposed mid-clear resets the busy state, so the next
shell's bar is not left busy. bindClearLayersControl stays exported and is
deprecated.
This commit is contained in:
Sameh Khamis committed 2026-11-01 13:23:51 -08:00
1 parent f269f5be4f
commit 7edf8a577e
15 files changed
+242 -75

No files matched your search

+50 -33
View File
@@ -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';
@@ -63,6 +62,7 @@ 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';
/**
* Central UI orchestrator for the God's Eye View application.
@@ -1009,7 +1009,8 @@ export class StyleManager extends ShellFacade {
if (!this._disposed) this._showToast(message);
},
setClearBusy: (busy) => {
if (!this._disposed) this._clearLayersControl?.setBusy(busy);
if (!this._disposed)
useGlobeActionsStore(applicationPinia()).publishClearBusy(busy);
},
},
});
@@ -1619,14 +1620,19 @@ 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();
},
});
}
/** Wire Google Maps-style tilt and north-up camera actions. */
@@ -1654,29 +1660,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 ───────────────────────────────
@@ -1778,7 +1790,12 @@ export class StyleManager extends ShellFacade {
useDisplayStore(applicationPinia()).connectMapSource(null);
useDisplayStore(applicationPinia()).publishMapSource(null);
this._cameraOrientationControls?.destroy();
this._clearLayersControl?.destroy();
const globeActions = useGlobeActionsStore(applicationPinia());
if (this._clearLayersToken) globeActions.detach(this._clearLayersToken);
if (this._shareToken) globeActions.detach(this._shareToken);
this._clearLayersToken = this._shareToken = null;
// A clear this shell leaves running must not keep the next shell's bar busy.
globeActions.publishClearBusy(null);
this._cctvControls?.destroy();
this.services?.streetLevelLayer?.attachNavigation?.(null);
this._streetLevelControls?.destroy();
+7 -1
View File
@@ -1,6 +1,12 @@
import { t } from '../i18n/index.js';
/** Keep the clear action focusable while busy; the caller owns its transaction. */
/**
* Keep the clear action focusable while busy; the caller owns its
* transaction. The globe actions view renders the button from the globe
* actions store now; this stays exported from `./ui/layers` for hosts that
* imported it.
* @deprecated
*/
export function bindClearLayersControl(button, clear) {
let destroyed = false;
const click = () => {
+69
View File
@@ -0,0 +1,69 @@
// 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';
function mount(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useGlobeActionsStore(pinia);
const calls = [];
store.connect({
clear: () => calls.push('clear'),
share: () => calls.push('share'),
});
const app = createApp(GlobeActions).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
return { calls, store };
}
const $ = (id) => document.getElementById(id);
test('the clear and share buttons call their owners', (t) => {
const { calls } = mount(t);
$('clear-selected-layers').click();
$('share-btn').click();
assert.deepEqual(calls, ['clear', 'share']);
});
test('a clear under way marks the button busy and keeps it focusable', async (t) => {
const { store } = 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(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(false);
await nextTick();
assert.equal(clear.getAttribute('aria-busy'), 'false');
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
// A shell disposed mid-clear hands the next one a bar that is not busy.
store.publishClearBusy(true);
store.publishClearBusy(null);
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']);
});
+27 -6
View File
@@ -6,6 +6,8 @@ import { StyleManager } from './applicationShell.js';
import { ShareLinkManager } from '../sharelink.js';
import { parseSharePath } from '../share/shareLinkPath.js';
import { decodeShareView } from '../share/viewCodec.js';
import { applicationPinia } from './vueRuntime.js';
import { useGlobeActionsStore } from './stores/globeActions.js';
/** The decoded view of a `/share/<code>` link. */
const sharedState = (href) =>
@@ -36,20 +38,19 @@ function installClipboard(writeText) {
});
}
/** Wire the real share button handler onto a minimal shell; return a click. */
/** The real share action on a minimal shell; return a click. */
function shell({ manager, services = {} }) {
const toasts = [];
let click;
const owner = {
services,
shareLinkManager: manager,
_shareBtn: {},
_disposed: false,
_lifetime: { listen: (_el, _type, handler) => (click = handler) },
_showToast: (message) => toasts.push(message),
};
StyleManager.prototype._initShareButton.call(owner);
return { toasts, click: () => click() };
return {
toasts,
click: () => StyleManager.prototype._share.call(owner),
};
}
test('buildLink is a /share link stamped with its creation time, without ?lang', () => {
@@ -158,3 +159,23 @@ test('a host hook that answers without a promise, or throws, still gets a toast'
assert.deepEqual(toasts, [toast]);
}
});
test('the shell answers the globe bar’s share and clear until it is disposed', async () => {
const calls = [];
const owner = {
_disposed: false,
_share: () => calls.push('share'),
clearSelectedLayers: async () => calls.push('clear'),
};
StyleManager.prototype._initShareButton.call(owner);
StyleManager.prototype._initClearSelectedLayersButton.call(owner);
const store = useGlobeActionsStore(applicationPinia());
store.act('share');
store.act('clear');
assert.deepEqual(calls, ['share', 'clear']);
store.detach(owner._shareToken);
store.detach(owner._clearLayersToken);
store.act('share');
store.act('clear');
assert.deepEqual(calls, ['share', 'clear']);
});
-2
View File
@@ -63,10 +63,8 @@ export function readShellElements(document = globalThis.document) {
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
_cctvMeta: document.getElementById('cctv-meta'),
_cctvSummary: document.getElementById('cctv-summary'),
_shareBtn: document.getElementById('share-btn'),
_tiltMapBtn: document.getElementById('tilt-map-view'),
_northUpBtn: document.getElementById('north-up-view'),
_clearSelectedLayersBtn: document.getElementById('clear-selected-layers'),
_resetGlobeBtn: document.getElementById('reset-globe-view'),
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
_locationSearch: document.getElementById('location-search'),
+22
View File
@@ -0,0 +1,22 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { createOwners } from './owners.js';
/**
* The top-center globe actions as their view renders them. The shell
* publishes whether a clear of the selected data layers is under way, and
* answers the clear and share buttons through act().
*/
export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
/** Whether a clear is under way; null until the shell first says. */
const clearBusy = ref(null);
/** @param {boolean | null} busy null when no shell answers the clear. */
function publishClearBusy(busy) {
clearBusy.value = busy == null ? null : Boolean(busy);
}
const { connect, detach, act } = createOwners();
return { clearBusy, publishClearBusy, connect, detach, act };
});
+2 -18
View File
@@ -16,6 +16,7 @@ import CelestialRing from './panels/CelestialRing.vue';
import ScreenAnnotations from './panels/ScreenAnnotations.vue';
import AwarenessDirection from './panels/AwarenessDirection.vue';
import UiButton from '../kit/UiButton.vue';
import GlobeActions from './panels/GlobeActions.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
const { style } = storeToRefs(useDisplayStore());
@@ -79,24 +80,7 @@ const vToastText = {
</div>
<!-- Top-center camera and share actions -->
<nav id="top-center-actions" aria-label="Globe actions" data-i18n-aria-label>
<button id="clear-selected-layers" type="button" aria-label="Clear selected data layers" title="Turn off all selected data layers" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">layers_clear</span>
</button>
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link" data-i18n-aria-label data-i18n-title>
<span class="share-icon" aria-hidden="true">&#x1F517;</span>
</button>
<button id="tilt-map-view" type="button" aria-label="Tilt map to oblique view" aria-pressed="false" title="Toggle straight-down and tilted map views" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</span>
</button>
<button id="north-up-view" type="button" aria-label="Reset map to north up" title="Reset map bearing to north" data-i18n-aria-label data-i18n-title>
<span class="camera-compass-n" aria-hidden="true">N</span>
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
</button>
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>
<GlobeActions />
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" :hidden="loading ? loading.hidden : true" :data-state="loading?.state ?? undefined">
<SplitFlapText id="global-loading-label" :text="loading ? loading.label : t('LOADING LIVE DATA')" :stopped="!!loading?.stopped" />
<span id="global-loading-detail">{{ loading ? loading.detail : '' }}</span>
+33
View File
@@ -0,0 +1,33 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js';
import { useGlobeActionsStore } from '../../stores/globeActions.js';
// The top-center camera and share actions. The shell answers the clear and
// share buttons through the store and publishes a clear under way, which
// keeps the button focusable and marks it busy.
const globe = useGlobeActionsStore();
const { clearBusy } = storeToRefs(globe);
const { act } = globe;
</script>
<template>
<nav id="top-center-actions" aria-label="Globe actions" data-i18n-aria-label>
<button id="clear-selected-layers" type="button" :aria-label="clearBusy ? t('Clearing selected data layers') : t('Clear selected data layers')" :aria-disabled="clearBusy == null ? undefined : String(clearBusy)" :aria-busy="clearBusy == null ? undefined : String(clearBusy)" title="Turn off all selected data layers" data-i18n-title @click="act('clear')">
<span class="material-symbols-outlined" aria-hidden="true">layers_clear</span>
</button>
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link" data-i18n-aria-label data-i18n-title @click="act('share')">
<span class="share-icon" aria-hidden="true">&#x1F517;</span>
</button>
<button id="tilt-map-view" type="button" aria-label="Tilt map to oblique view" aria-pressed="false" title="Toggle straight-down and tilted map views" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</span>
</button>
<button id="north-up-view" type="button" aria-label="Reset map to north up" title="Reset map bearing to north" data-i18n-aria-label data-i18n-title>
<span class="camera-compass-n" aria-hidden="true">N</span>
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
</button>
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>
</template>