diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b54ac70b..31365d3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,8 +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 and share buttons call the shell through the new - globe actions store. `syncScopeControls` (`./ui/display`), + 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`), `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 diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 17156d7a6..8d88bc0a2 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -226,9 +226,13 @@ a feature supplying only its accent. 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 and share buttons - call the shell through the `gev-globe-actions` store, which also carries - a clear under way), the Scene actions panel (`SceneInteractions` + `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 + 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 + its state alone), the Scene actions panel (`SceneInteractions` teleports what the scene interactions owner publishes to the `gev-scene-interactions` store into the viewer's container; the owner keeps the pick handler and focuses the picked action), the data-pack diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0c5be407d..876228af9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -140,7 +140,8 @@ "src/ui/kit/UiEmpty.vue", "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", - "src/ui/views/panels/GlobeActions.vue" + "src/ui/views/panels/GlobeActions.vue", + "src/ui/views/panels/GlobeCompass.vue" ], "external": ["vue", "pinia"] }, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 90fed39b9..07f07f570 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -788,8 +788,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.match(synchronous, /this\._displayBindings\.destroy\(\)/); assert.match(synchronous, /this\._visualSettings\.stop\(\)/); assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/); - assert.match(synchronous, /globeActions\.detach\(this\._clearLayersToken\)/); - assert.match(synchronous, /globeActions\.detach\(this\._shareToken\)/); + assert.match(synchronous, /this\._releaseGlobeActions\(\)/); assert.match(synchronous, /this\._locationNavigation\.destroy\(\)/); assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationControls\?\.destroy\(\)/); assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 0bef67fb0..885bbc0a3 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1008,10 +1008,7 @@ export class StyleManager extends ShellFacade { showToast: (message) => { if (!this._disposed) this._showToast(message); }, - setClearBusy: (busy) => { - if (!this._disposed) - useGlobeActionsStore(applicationPinia()).publishClearBusy(busy); - }, + setClearBusy: (busy) => this._publishClearBusy(busy), }, }); // The Contacts layer publishes its panel; the view calls its navigation. @@ -1633,21 +1630,64 @@ export class StyleManager extends ShellFacade { 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, + ); + } + + /** + * 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); + this._clearLayersToken = this._shareToken = this._orientationToken = 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 ───────────────────────────── @@ -1790,12 +1830,7 @@ export class StyleManager extends ShellFacade { useDisplayStore(applicationPinia()).connectMapSource(null); useDisplayStore(applicationPinia()).publishMapSource(null); this._cameraOrientationControls?.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._releaseGlobeActions(); this._cctvControls?.destroy(); this.services?.streetLevelLayer?.attachNavigation?.(null); this._streetLevelControls?.destroy(); diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index d10bacf2f..8b7857784 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -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, diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index accd003f8..68ba5ac49 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -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(); diff --git a/src/ui/globeActions.test.mjs b/src/ui/globeActions.test.mjs index 373dbb6aa..ea0b81138 100644 --- a/src/ui/globeActions.test.mjs +++ b/src/ui/globeActions.test.mjs @@ -14,30 +14,71 @@ function mount(t) { const pinia = createPinia(); const store = useGlobeActionsStore(pinia); const calls = []; - store.connect({ + const token = store.connect({ clear: () => calls.push('clear'), share: () => calls.push('share'), + tilt: () => calls.push('tilt'), + northUp: () => calls.push('northUp'), }); const app = createApp(GlobeActions).use(pinia); app.mount(document.body.appendChild(document.createElement('div'))); t.after(() => app.unmount()); - return { calls, store }; + return { calls, store, token }; } const $ = (id) => document.getElementById(id); -test('the clear and share buttons call their owners', (t) => { +test('every button calls its owner', (t) => { const { calls } = mount(t); $('clear-selected-layers').click(); $('share-btn').click(); - assert.deepEqual(calls, ['clear', 'share']); + $('tilt-map-view').click(); + $('north-up-view').click(); + assert.deepEqual(calls, ['clear', 'share', 'tilt', 'northUp']); +}); + +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 } = mount(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(true); + store.publishClearBusy(token, true); await nextTick(); assert.equal(clear.getAttribute('aria-busy'), 'true'); assert.equal(clear.getAttribute('aria-disabled'), 'true'); @@ -46,13 +87,24 @@ test('a clear under way marks the button busy and keeps it focusable', async (t) 'Clearing selected data layers', ); assert.equal(clear.disabled, false); - store.publishClearBusy(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.publishClearBusy(true); - store.publishClearBusy(null); + store.detach(token); await nextTick(); assert.equal(clear.hasAttribute('aria-busy'), false); assert.equal(clear.hasAttribute('aria-disabled'), false); @@ -67,3 +119,14 @@ test('a detached owner no longer answers', (t) => { $('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 }); +}); diff --git a/src/ui/shareButton.test.mjs b/src/ui/shareButton.test.mjs index d3a95d421..bf39ac1ee 100644 --- a/src/ui/shareButton.test.mjs +++ b/src/ui/shareButton.test.mjs @@ -160,7 +160,7 @@ test('a host hook that answers without a promise, or throws, still gets a toast' } }); -test('the shell answers the globe bar’s share and clear until it is disposed', async () => { +test('the shell answers the globe bar until it is disposed', async () => { const calls = []; const owner = { _disposed: false, @@ -173,9 +173,130 @@ test('the shell answers the globe bar’s share and clear until it is disposed', store.act('share'); store.act('clear'); assert.deepEqual(calls, ['share', 'clear']); + // A newer shell answers, and owns the busy state, until it goes. + const newer = { _disposed: false, clearSelectedLayers: async () => {} }; + StyleManager.prototype._initClearSelectedLayersButton.call(newer); + assert.equal(store.owns(owner._clearLayersToken, 'clear'), false); + store.detach(newer._clearLayersToken); + assert.equal(store.owns(owner._clearLayersToken, 'clear'), true); store.detach(owner._shareToken); store.detach(owner._clearLayersToken); store.act('share'); store.act('clear'); assert.deepEqual(calls, ['share', 'clear']); }); + +test('the shell’s camera controls answer tilt and north-up and publish their state', () => { + const store = useGlobeActionsStore(applicationPinia()); + const navigations = []; + const owner = { + viewer: { camera: { heading: 0 } }, + _navigation: { + runOrientation: (noun, navigate) => { + navigations.push(noun); + return navigate(); + }, + }, + _showToast() {}, + }; + StyleManager.prototype._initCameraOrientationControls.call(owner); + assert.deepEqual(store.camera, { tilted: false, heading: 0 }); + store.act('tilt'); + store.act('northUp'); + assert.deepEqual(navigations, ['camera', 'camera']); + + // Initialised again, the shell replaces its own owner. + const first = owner._orientationToken; + StyleManager.prototype._initCameraOrientationControls.call(owner); + assert.notEqual(owner._orientationToken, first); + assert.equal(store.owns(first, 'tilt'), false); + assert.equal(store.owns(owner._orientationToken, 'tilt'), true); + + // A newer shell owns what the buttons show. + const newer = store.connect({ tilt() {} }); + owner.viewer.camera.heading = Math.PI / 2; + owner._cameraOrientationControls.sync(); + assert.deepEqual(store.camera, { tilted: false, heading: 0 }); + store.detach(newer); + owner.viewer.camera.heading = Math.PI; + owner._cameraOrientationControls.sync(); + assert.deepEqual(store.camera, { tilted: false, heading: 180 }); + owner._cameraOrientationControls.destroy(); + store.detach(owner._orientationToken); +}); + +test('a shell that goes after a newer one leaves the newer one’s bar state', () => { + const store = useGlobeActionsStore(applicationPinia()); + const shellWith = () => { + const owner = { + _disposed: false, + viewer: { camera: { heading: 0 } }, + _navigation: { runOrientation: (_noun, navigate) => navigate() }, + _showToast() {}, + clearSelectedLayers: async () => {}, + }; + StyleManager.prototype._initClearSelectedLayersButton.call(owner); + StyleManager.prototype._initCameraOrientationControls.call(owner); + return owner; + }; + const older = shellWith(); + const newer = shellWith(); + newer.viewer.camera.heading = Math.PI / 2; + newer._cameraOrientationControls.sync(); + StyleManager.prototype._publishClearBusy.call(newer, true); + StyleManager.prototype._publishClearBusy.call(older, false); + assert.equal(store.clearBusy, true); + + older._cameraOrientationControls.destroy(); + StyleManager.prototype._releaseGlobeActions.call(older); + assert.equal(store.clearBusy, true); + assert.deepEqual(store.camera, { tilted: false, heading: 90 }); + + // The shell that answers resets its state as it goes. + newer._cameraOrientationControls.destroy(); + StyleManager.prototype._releaseGlobeActions.call(newer); + assert.equal(store.clearBusy, null); + assert.equal(store.camera, null); + // Its clear finishing after it went changes nothing. + newer._disposed = true; + StyleManager.prototype._publishClearBusy.call(newer, false); + assert.equal(store.clearBusy, null); +}); + +test('an older shell that answers again shows its clear and its unchanged camera', () => { + const store = useGlobeActionsStore(applicationPinia()); + const shellWith = (heading) => { + const owner = { + _disposed: false, + viewer: { camera: { heading } }, + _navigation: { runOrientation: (_noun, navigate) => navigate() }, + _showToast() {}, + clearSelectedLayers: async () => {}, + }; + StyleManager.prototype._initClearSelectedLayersButton.call(owner); + StyleManager.prototype._initCameraOrientationControls.call(owner); + return owner; + }; + const older = shellWith(Math.PI / 2); + StyleManager.prototype._publishClearBusy.call(older, true); + const newer = shellWith(0); + // The newer shell's bar does not show the older one's clear. + assert.equal(store.clearBusy, null); + older._cameraOrientationControls.sync(); + assert.deepEqual(store.camera, { tilted: false, heading: 0 }); + // Initialised again mid-clear, a shell keeps its clear on the button. + StyleManager.prototype._publishClearBusy.call(newer, true); + StyleManager.prototype._initClearSelectedLayersButton.call(newer); + assert.equal(store.clearBusy, true); + StyleManager.prototype._publishClearBusy.call(newer, false); + newer._cameraOrientationControls.destroy(); + StyleManager.prototype._releaseGlobeActions.call(newer); + assert.equal(store.camera, null); + // The older shell's clear is the button's again, and the next frame it + // draws puts its heading back. + assert.equal(store.clearBusy, true); + older._cameraOrientationControls.sync(); + assert.deepEqual(store.camera, { tilted: false, heading: 90 }); + older._cameraOrientationControls.destroy(); + StyleManager.prototype._releaseGlobeActions.call(older); +}); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 35833bdf6..561549296 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'), - _tiltMapBtn: document.getElementById('tilt-map-view'), - _northUpBtn: document.getElementById('north-up-view'), _resetGlobeBtn: document.getElementById('reset-globe-view'), _cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'), _locationSearch: document.getElementById('location-search'), diff --git a/src/ui/stores/globeActions.js b/src/ui/stores/globeActions.js index fb83d8140..d8dff5d14 100644 --- a/src/ui/stores/globeActions.js +++ b/src/ui/stores/globeActions.js @@ -1,22 +1,82 @@ import { defineStore } from 'pinia'; -import { ref } from 'vue'; +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 - * answers the clear and share buttons through act(). + * 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(). */ export const useGlobeActionsStore = defineStore('gev-globe-actions', () => { - /** Whether a clear is under way; null until the shell first says. */ - const clearBusy = ref(null); + 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()); - /** @param {boolean | null} busy null when no shell answers the clear. */ - function publishClearBusy(busy) { - clearBusy.value = busy == null ? null : Boolean(busy); + /** + * 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. + */ + 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)); } - const { connect, detach, act } = createOwners(); + /** + * Whether the map reads as tilted and the camera's whole-degree heading; + * null until the camera controls first say. + * @type {import('vue').Ref<{ tilted: boolean, heading: number } | null>} + */ + const camera = ref(null); - return { clearBusy, publishClearBusy, connect, detach, act }; + /** + * Take the camera's state; the same state again changes nothing, so its + * owner can publish it on every frame the camera draws. + * @param {{ tilted: boolean, heading: number } | null} state + */ + function publishCamera(state) { + const current = camera.value; + if (!state) { + camera.value = null; + return; + } + const tilted = Boolean(state.tilted); + if (current?.tilted === tilted && current.heading === state.heading) return; + camera.value = { tilted, heading: state.heading }; + } + + /** @param {Record} actions */ + function connect(actions) { + const token = owners.connect(actions); + ownership.value += 1; + return token; + } + + function detach(token) { + owners.detach(token); + busyBy.delete(token); + ownership.value += 1; + } + + const { act, owns } = owners; + + return { + clearBusy, + publishClearBusy, + camera, + publishCamera, + connect, + detach, + act, + owns, + }; }); diff --git a/src/ui/stores/owners.js b/src/ui/stores/owners.js index 579d9bc60..de9d4ae30 100644 --- a/src/ui/stores/owners.js +++ b/src/ui/stores/owners.js @@ -3,7 +3,7 @@ * the newest connected owner with an action of that name answers it, so an * owner that connects again replaces itself, and owners of different actions * sit side by side. - * @returns {{connect: Function, detach: Function, act: Function}} + * @returns {{connect: Function, detach: Function, act: Function, owns: Function, ownerOf: Function}} */ export function createOwners() { /** @type {Array<{actions: object}>} Oldest first. */ @@ -40,5 +40,25 @@ export function createOwners() { } } - return { connect, detach, act }; + /** + * The token of the owner that answers `name`, if any. + * @param {string} name + * @returns {object | undefined} + */ + function ownerOf(name) { + for (let index = owners.length - 1; index >= 0; index -= 1) + if (owners[index].actions[name]) return owners[index]; + } + + /** + * Whether the owner holding `token` is the one that answers `name`, so an + * owner that also publishes state can leave it to a newer one. + * @param {object} token + * @param {string} name + */ + function owns(token, name) { + return Boolean(token) && ownerOf(name) === token; + } + + return { connect, detach, act, owns, ownerOf }; } diff --git a/src/ui/stores/owners.test.mjs b/src/ui/stores/owners.test.mjs index 83cef8b99..facf16205 100644 --- a/src/ui/stores/owners.test.mjs +++ b/src/ui/stores/owners.test.mjs @@ -33,3 +33,25 @@ test('act returns what the answering action returned', () => { assert.equal(act('pick', 'jazz'), 'kept jazz'); assert.equal(act('missing'), undefined); }); + +test('owns names the owner that answers an action', () => { + const { connect, detach, owns } = createOwners(); + const older = connect({ tilt() {}, share() {} }); + const newer = connect({ tilt() {} }); + assert.equal(owns(newer, 'tilt'), true); + assert.equal(owns(older, 'tilt'), false); + assert.equal(owns(older, 'share'), true); + assert.equal(owns(newer, 'missing'), false); + assert.equal(owns(null, 'tilt'), false); + detach(newer); + assert.equal(owns(older, 'tilt'), true); +}); + +test('ownerOf names the token that answers an action', () => { + const { connect, ownerOf } = createOwners(); + const older = connect({ clear() {}, share() {} }); + const newer = connect({ clear() {} }); + assert.equal(ownerOf('clear'), newer); + assert.equal(ownerOf('share'), older); + assert.equal(ownerOf('missing'), undefined); +}); diff --git a/src/ui/views/panels/GlobeActions.vue b/src/ui/views/panels/GlobeActions.vue index ea058cdba..ce410d54a 100644 --- a/src/ui/views/panels/GlobeActions.vue +++ b/src/ui/views/panels/GlobeActions.vue @@ -1,14 +1,19 @@