diff --git a/CHANGELOG.md b/CHANGELOG.md index 31365d3e8..0f5f0e6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,9 +72,9 @@ are the kit's new `UiChip`. The selects are the kit's new `UiSelect`, the status badges its new `UiBadge` and the one-line empty states its new `UiEmpty`. - The top-center clear, share, tilt and north-up buttons call the shell - through the new globe actions store, which also carries the camera's tilt - and compass heading. `syncScopeControls` (`./ui/display`), + The top-center clear, share, tilt, north-up and reset buttons, and the + cockpit's reset, call the shell through the new globe actions store, + which also carries the camera's tilt and compass heading. `syncScopeControls` (`./ui/display`), `shouldHideCollapsedRightPanels` (`./ui/layout`) and `bindClearLayersControl` (`./ui/layers`) are deprecated: the Display view, the rail and the globe actions view now own what they did. `./ui/input` imports Vue @@ -113,8 +113,9 @@ landmark pills or writes the mini status: it presents the tray, including the search error (`setSearchError()`), and hands it to its `publish` option, and Core's dock renders it and calls `chooseCity()` and - `choosePoi()`. Its `elements` are now only `search`, `searchToggle` and - `resetButtons`. `createOrbitIndicator()` is replaced by + `choosePoi()`. Its `elements` are now only `search` and `searchToggle`: + `resetButtons` is gone, and the reset buttons call its new `reset()`. + `createOrbitIndicator()` is replaced by `showOrbit(active)`, which adds the ORBIT indicator to the presented tray as `orbit: { active }` (null until offered) (samehkhamis). diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 8d88bc0a2..e3d04b7b7 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -225,10 +225,11 @@ a feature supplying only its accent. `splitFlap.js` plans, writing only the label's one text node; the toast renders what each owner's `createToast()` publishes there, on that owner's own timer, and the title bar's FPS readout what - `createFrameRateMonitor()` publishes), the globe actions (in progress: - `GlobeActions` renders the top-center bar; its clear, share, tilt and - north-up buttons call the shell through the `gev-globe-actions` store, - which also carries a clear under way and the camera's tilt and heading + `createFrameRateMonitor()` publishes), the globe actions + (`GlobeActions` renders the top-center bar; its clear, share, tilt, + north-up and reset buttons, and the cockpit's reset, call the shell + through the `gev-globe-actions` store, which also carries a clear or a + reset under way and the camera's tilt and heading that `bindCameraOrientationControls` publishes each drawn frame it changes, which only `GlobeCompass` renders; an owner publishes only while it answers the button, so a shell replaced by a newer one leaves diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 92e10c5f9..dc80bd279 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -296,7 +296,7 @@ try { 'native reset returns through the existing camera handoff', await page.evaluate(async () => { const ui = window.__godsEyeView.styleManager; - ui._resetGlobeBtn.click(); + document.getElementById('reset-globe-view').click(); const pending = ui._globeResetPromise; if (!pending) return false; await pending; @@ -317,7 +317,7 @@ try { .getElementById('location-pills') .querySelector('button') ?.click(); - ui._resetGlobeBtn.click(); + document.getElementById('reset-globe-view').click(); ui._locationSearch.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), ); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index a404ab381..0bc262348 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -795,35 +795,29 @@ test('Reset releases Contact camera ownership through its selection-preserving r assert.ok(resetStart >= 0); assert.ok(contextRelease > resetStart); assert.ok(satelliteRelease > contextRelease); - assert.match( - readShellElements.toString(), - /_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/, - ); const controls = fs.readFileSync( path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8', ); - assert.match( - ui, - /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/, - ); assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/); - assert.match( - controls, - /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/, - 'both reset controls delegate to the same supplied reset action', + assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/); + assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/); + // Both reset buttons ask the one reset owner. + const globeActions = fs.readFileSync( + path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'), + 'utf8', ); + assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/); + const cockpitView = readRadioSource( + new URL('./ui/views/CockpitView.vue', import.meta.url), + 'utf8', + ); + assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/); assert.match( cockpitEnter.toString(), /Object\.assign\(this\.presence, \{\s*active: true,/, ); - assert.match( - readRadioSource( - new URL('./ui/views/CockpitView.vue', import.meta.url), - 'utf8', - ), - /id="cockpit-reset-globe"[^>]*:hidden="!active"/, - ); + assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*:hidden="!active"/); }); test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 885bbc0a3..eed23487c 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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. */ diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index d8cb65dd2..a65b437fe 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -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'); diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index 4e8983ac0..742cea1e6 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -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' }] }, diff --git a/src/ui/globeActions.test.mjs b/src/ui/globeActions.test.mjs index ea0b81138..9b32908f4 100644 --- a/src/ui/globeActions.test.mjs +++ b/src/ui/globeActions.test.mjs @@ -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', + ); +}); diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 4a94dc4e2..8977c6fc2 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -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) diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 42466ee19..b62478fc8 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -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', () => { diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index a9c9d5a71..f06515c6e 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -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; diff --git a/src/ui/placeNames.test.mjs b/src/ui/placeNames.test.mjs index 0c224eaee..0e1536ffa 100644 --- a/src/ui/placeNames.test.mjs +++ b/src/ui/placeNames.test.mjs @@ -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 }; diff --git a/src/ui/shareButton.test.mjs b/src/ui/shareButton.test.mjs index bf39ac1ee..7de669a83 100644 --- a/src/ui/shareButton.test.mjs +++ b/src/ui/shareButton.test.mjs @@ -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); +}); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 561549296..fa24026aa 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -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'), diff --git a/src/ui/stores/globeActions.js b/src/ui/stores/globeActions.js index d8dff5d14..58247604f 100644 --- a/src/ui/stores/globeActions.js +++ b/src/ui/stores/globeActions.js @@ -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, diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index 1c0af4be9..4ec4ea9ab 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -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) =>