feat(globe): answer both reset buttons through the globe actions store

The top-center and cockpit reset buttons call the location controls'
new reset() through the globe actions store, and render a reset under
way from it, instead of the location controls binding their clicks and
the navigation writing their labels. The store keeps each owner's reset
under way like its clear, and controls replaced mid-reset keep it on the
buttons. LocationControls no longer takes resetButtons.
This commit is contained in:
Sameh Khamis committed 2026-11-01 13:43:57 -08:00
1 parent eccaeea0c4
commit b4ee2321cf
17 files changed
+173 -84

No files matched your search

+30 -2
View File
@@ -322,14 +322,14 @@ export class StyleManager extends ShellFacade {
elements: {
_locationSearch: this._locationSearch,
_searchToggle: this._searchToggle,
_resetGlobeBtn: this._resetGlobeBtn,
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
},
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) =>
@@ -1649,6 +1649,32 @@ export class StyleManager extends ShellFacade {
);
}
/**
* 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.
@@ -1659,7 +1685,9 @@ export class StyleManager extends ShellFacade {
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. */
-1
View File
@@ -97,7 +97,6 @@ export class CockpitViewController {
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.visionPrevious = document.getElementById('cockpit-vision-previous');
-2
View File
@@ -169,7 +169,6 @@ test('the ORBIT indicator sits on the body while offered and lights while orbiti
elements: {
search: $('#location-search'),
searchToggle: $('#search-toggle'),
resetButtons: [],
},
cities: {},
getExpandedCity: () => null,
@@ -211,7 +210,6 @@ test('the location tray renders what LocationControls publishes', async (t) => {
elements: {
search: $('#location-search'),
searchToggle: $('#search-toggle'),
resetButtons: [],
},
cities: {
paris: { name: 'Paris', pois: [{ name: 'Louvre' }, { name: 'Eiffel' }] },
+44 -1
View File
@@ -8,6 +8,7 @@ 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();
@@ -19,6 +20,7 @@ function mount(t) {
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')));
@@ -33,7 +35,8 @@ test('every button calls its owner', (t) => {
$('share-btn').click();
$('tilt-map-view').click();
$('north-up-view').click();
assert.deepEqual(calls, ['clear', 'share', 'tilt', 'northUp']);
$('reset-globe-view').click();
assert.deepEqual(calls, ['clear', 'share', 'tilt', 'northUp', 'reset']);
});
test('the tilt and compass buttons show the published camera', async (t) => {
@@ -130,3 +133,43 @@ test('the same camera state again changes nothing', () => {
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',
);
});
+6 -3
View File
@@ -19,7 +19,8 @@ const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze(
* mini status, the search error and the ORBIT indicator are presented as a
* plain view and handed to `publish`; Core's dock renders them and calls
* `chooseCity()` and `choosePoi()`, and the scene chrome shows the indicator.
* The search field and reset buttons keep their listeners.
* The globe actions and the cockpit call `reset()`. The search field keeps its
* listeners.
*/
export class LocationControls {
constructor({
@@ -90,8 +91,6 @@ export class LocationControls {
this.bind(elements.search, 'blur', () => {
if (!elements.search.value) onSearchEdit?.();
});
for (const button of elements.resetButtons)
this.bind(button, 'click', onReset);
this.present();
}
bind(element, event, handler, removers = this.removers) {
@@ -132,6 +131,10 @@ export class LocationControls {
chooseCity(id) {
if (!this.destroyed) this.onCity(id);
}
/** A reset to the full globe view was asked for. */
reset() {
if (!this.destroyed) return this.onReset();
}
/** A landmark pill of the shown city was clicked. */
choosePoi(index) {
if (!this.destroyed && this.poiCity !== null)
+2 -3
View File
@@ -61,7 +61,6 @@ function fixture() {
const elements = {
search: node(),
searchToggle: node(),
resetButtons: [node(), node()],
};
const doc = node();
doc.createElement = node;
@@ -168,7 +167,7 @@ test('destruction revokes document, search, reset, city and POI actions and remo
f.elements.search.value = 'Place';
f.elements.search.fire('keydown', { key: 'Enter' });
f.doc.fire('keydown', { key: 'Q' });
f.elements.resetButtons[0].fire('click');
f.controls.reset();
f.frames.get(0)();
assert.deepEqual(f.calls, []);
assert.equal(f.views.length, published);
@@ -187,7 +186,7 @@ test('location and POI keys route once while form controls retain typing', () =>
const f = fixture();
f.doc.fire('keydown', { key: 'W', target: { matches: () => false } });
f.doc.fire('keydown', { key: 'Q', target: { matches: () => true } });
f.elements.resetButtons[1].fire('click');
f.controls.reset();
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
});
test('typing clears a failed search error and closing the emptied field drops it too', () => {
+4 -17
View File
@@ -144,7 +144,6 @@ export class LocationNavigation {
elements: {
search: this._locationSearch,
searchToggle: this._searchToggle,
resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
},
publish: (view) => this._publishLocation?.(view),
cities: CITY_POIS,
@@ -156,6 +155,7 @@ export class LocationNavigation {
onReset: () => this.resetToGlobeView(),
});
this._connectLocation?.(this._locationControls);
this._connectGlobeReset?.(this._locationControls);
}
_beginWorldJumpTransition() {
@@ -390,14 +390,7 @@ export class LocationNavigation {
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(2)),
},
};
this._resetGlobeBtn?.setAttribute(
'aria-label',
t('Reset to full globe view'),
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
t('Reset cockpit to full globe view'),
);
this._publishGlobeResetting?.(false);
this._globeResetPromise = null;
this._cancelGlobeReset = null;
resolveReset(result);
@@ -410,14 +403,7 @@ export class LocationNavigation {
Math.abs(height - GLOBE_VIEW.heightM) > 1000,
);
}, 4200);
this._resetGlobeBtn?.setAttribute(
'aria-label',
t('Resetting to full globe view'),
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
t('Resetting cockpit to full globe view'),
);
this._publishGlobeResetting?.(true);
const target = flyToGlobeView(this.viewer, {
onComplete: () => finish(false),
onCancel: () => finish(true),
@@ -436,6 +422,7 @@ export class LocationNavigation {
this._locationLookup?.destroy();
this._locationControls?.destroy();
this._connectLocation?.(null);
this._connectGlobeReset?.(null);
this._publishLocation?.(null);
this._cancelGlobeReset?.();
this._cancelGlobeReset = null;
-1
View File
@@ -54,7 +54,6 @@ test('ru: location pills and the mini-status show the translated city', () => {
const elements = {
search: element(),
searchToggle: element(),
resetButtons: [],
};
const doc = { ...element(), createElement: element };
const cities = { tokyo: CITY_POIS.tokyo };
+23
View File
@@ -300,3 +300,26 @@ test('an older shell that answers again shows its clear and its unchanged camera
older._cameraOrientationControls.destroy();
StyleManager.prototype._releaseGlobeActions.call(older);
});
test('the location controls answer both reset buttons until they go', () => {
const store = useGlobeActionsStore(applicationPinia());
const calls = [];
const owner = { _disposed: false };
const controls = { reset: () => calls.push('reset') };
StyleManager.prototype._connectGlobeReset.call(owner, controls);
store.act('reset');
StyleManager.prototype._publishGlobeResetting.call(owner, true);
assert.equal(store.resetBusy, true);
// Controls replaced mid-reset keep the reset on the buttons.
StyleManager.prototype._connectGlobeReset.call(owner, controls);
assert.equal(store.resetBusy, true);
StyleManager.prototype._publishGlobeResetting.call(owner, false);
assert.equal(store.resetBusy, false);
StyleManager.prototype._connectGlobeReset.call(owner, null);
store.act('reset');
assert.deepEqual(calls, ['reset']);
assert.equal(store.resetBusy, null);
// A reset finishing after the controls went changes nothing.
StyleManager.prototype._publishGlobeResetting.call(owner, false);
assert.equal(store.resetBusy, null);
});
-2
View File
@@ -63,8 +63,6 @@ export function readShellElements(document = globalThis.document) {
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
_cctvMeta: document.getElementById('cctv-meta'),
_cctvSummary: document.getElementById('cctv-summary'),
_resetGlobeBtn: document.getElementById('reset-globe-view'),
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
_locationSearch: document.getElementById('location-search'),
_searchToggle: document.getElementById('search-toggle'),
_safeFrameOverlay: document.getElementById('safe-frame-overlay'),
+32 -19
View File
@@ -3,33 +3,43 @@ import { computed, ref, shallowReactive } 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 the
* camera's tilt and heading, and answers the buttons through act().
* The top-center globe actions as their view renders them, and the cockpit's
* reset. The shell publishes whether a clear of the selected data layers or a
* reset to the full globe view is under way and the camera's tilt and
* heading, and answers the buttons through act().
*/
export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
const owners = createOwners();
// Who answers which action, as the computed state below reads it.
const ownership = ref(0);
/** Each clear owner's clear under way, by its token. */
const busyBy = shallowReactive(new Map());
/**
* Whether the clear owner's clear is under way; null until it says. Each
* owner keeps its own, so the button shows the one that answers it.
* Whether the work behind an action is under way, kept per owner so its
* button shows the owner that answers it; null until that owner says.
*/
const clearBusy = computed(() => {
void ownership.value;
return busyBy.get(owners.ownerOf('clear')) ?? null;
});
/**
* @param {object} token The clear owner's, from connect().
* @param {boolean} busy
*/
function publishClearBusy(token, busy) {
busyBy.set(token, Boolean(busy));
function ownedBusy(name) {
const byOwner = shallowReactive(new Map());
const busy = computed(() => {
void ownership.value;
return byOwner.get(owners.ownerOf(name)) ?? null;
});
/**
* @param {object} token The owner's, from connect().
* @param {boolean} value
*/
const publish = (token, value) => {
byOwner.set(token, Boolean(value));
};
return { byOwner, busy, publish };
}
const clearing = ownedBusy('clear');
const resetting = ownedBusy('reset');
/** Whether a clear of the selected data layers is under way. */
const clearBusy = clearing.busy;
const publishClearBusy = clearing.publish;
/** Whether a reset to the full globe view is under way. */
const resetBusy = resetting.busy;
const publishResetting = resetting.publish;
/**
* Whether the map reads as tilted and the camera's whole-degree heading;
@@ -63,7 +73,8 @@ export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
function detach(token) {
owners.detach(token);
busyBy.delete(token);
clearing.byOwner.delete(token);
resetting.byOwner.delete(token);
ownership.value += 1;
}
@@ -72,6 +83,8 @@ export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
return {
clearBusy,
publishClearBusy,
resetBusy,
publishResetting,
camera,
publishCamera,
connect,
+4 -1
View File
@@ -9,6 +9,7 @@ import {
} from '../cockpitInstrumentState.js';
import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js';
import { useGlobeActionsStore } from '../stores/globeActions.js';
import CockpitBriefing from './panels/CockpitBriefing.vue';
import CockpitContext from './panels/CockpitContext.vue';
import CockpitRollingValue from './panels/CockpitRollingValue.vue';
@@ -21,6 +22,8 @@ import CockpitUtilityControls from './panels/CockpitUtilityControls.vue';
// keep its listeners.
const { presence, instruments, route, vision } = storeToRefs(useCockpitStore());
const active = computed(() => Boolean(presence.value?.active));
const globe = useGlobeActionsStore();
const { resetBusy } = storeToRefs(globe);
const initialInstruments = createInstrumentState();
const initialRoute = createRouteState();
const initialVision = createVisionState();
@@ -188,7 +191,7 @@ const tickStyle = (tick) =>
</section>
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label>
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" :hidden="!active" data-i18n-aria-label data-i18n-title>
<button id="cockpit-reset-globe" type="button" :aria-label="resetBusy ? t('Resetting cockpit to full globe view') : t('Reset cockpit to full globe view')" title="Exit cockpit and return to full globe view" :hidden="!active" data-i18n-title @click="globe.act('reset')">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span data-i18n>RESET</span>
</button>
+2 -2
View File
@@ -11,7 +11,7 @@ import GlobeCompass from './GlobeCompass.vue';
// changes on every frame a rotating camera draws, so only the compass reads
// it; the bar renders again only when the tilt changes.
const globe = useGlobeActionsStore();
const { clearBusy, camera } = storeToRefs(globe);
const { clearBusy, resetBusy, camera } = storeToRefs(globe);
const { act } = globe;
const tilted = computed(() => Boolean(camera.value?.tilted));
</script>
@@ -28,7 +28,7 @@ const tilted = computed(() => Boolean(camera.value?.tilted));
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</span>
</button>
<GlobeCompass @click="act('northUp')" />
<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>
<button id="reset-globe-view" type="button" :aria-label="resetBusy ? t('Resetting to full globe view') : t('Reset to full globe view')" title="Reset camera and return to full globe view" data-i18n-title @click="act('reset')">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>