feat(globe): render the tilt and north-up buttons from the globe actions store

The camera orientation controls publish whether the map is tilted and the
camera's whole-degree heading instead of writing the buttons' attributes,
and answer tilt and north-up through the globe actions store. The new
GlobeCompass renders the heading, so a rotating camera re-renders only the
compass. The store drops an unchanged camera state and keeps each clear
owner's busy state, so the buttons show the shell that answers them: a
shell replaced by a newer one, or disposed mid-clear, leaves the live
shell's state alone, and an older shell that answers again shows its own.
createOwners gains owns() and ownerOf().
This commit is contained in:
Sameh Khamis committed 2026-11-01 13:37:06 -08:00
1 parent 7edf8a577e
commit eccaeea0c4
15 files changed
+495 -180

No files matched your search

+3 -2
View File
@@ -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
+7 -3
View File
@@ -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
+2 -1
View File
@@ -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"]
},
+1 -2
View File
@@ -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\(\)/);
+49 -14
View File
@@ -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();
+20 -41
View File
@@ -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,
+67 -84
View File
@@ -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();
+72 -9
View File
@@ -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 });
});
+122 -1
View File
@@ -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);
});
-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'),
_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'),
+70 -10
View File
@@ -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<string, Function>} 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,
};
});
+22 -2
View File
@@ -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 };
}
+22
View File
@@ -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);
});
+11 -9
View File
@@ -1,14 +1,19 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { useGlobeActionsStore } from '../../stores/globeActions.js';
import GlobeCompass from './GlobeCompass.vue';
// 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.
// The top-center camera and share actions. The shell answers the buttons
// through the store and publishes a clear under way, which keeps the button
// focusable and marks it busy, and the camera's tilt and heading. The heading
// 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 } = storeToRefs(globe);
const { clearBusy, camera } = storeToRefs(globe);
const { act } = globe;
const tilted = computed(() => Boolean(camera.value?.tilted));
</script>
<template>
@@ -19,13 +24,10 @@ const { act } = globe;
<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>
<button id="tilt-map-view" type="button" :aria-label="tilted ? t('Return map to straight-down view') : t('Tilt map to oblique view')" :aria-pressed="String(tilted)" title="Toggle straight-down and tilted map views" data-i18n-title @click="act('tilt')">
<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>
<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>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
+27
View File
@@ -0,0 +1,27 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { useGlobeActionsStore } from '../../stores/globeActions.js';
// The north-up button: its needle and label follow the published heading,
// which changes on every frame a rotating camera draws. Before the camera
// controls first publish, it shows no heading.
defineEmits(['click']);
const { camera } = storeToRefs(useGlobeActionsStore());
const heading = computed(() => camera.value?.heading);
const label = computed(() =>
heading.value == null
? t('Reset map to north up')
: t('Reset map to north up. Current heading {heading} degrees', {
heading: heading.value,
}),
);
</script>
<template>
<button id="north-up-view" type="button" :aria-label="label" :data-north-up="heading == null ? undefined : String(heading === 0)" :style="heading == null ? undefined : { '--camera-heading': `${heading}deg` }" title="Reset map bearing to north" data-i18n-title @click="$emit('click')">
<span class="camera-compass-n" aria-hidden="true">N</span>
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
</button>
</template>