mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
7edf8a577e
commit
eccaeea0c4
15 files changed
+494
-179
No files matched your search
+3
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name);
|
||||
}
|
||||
click() {
|
||||
this.dispatchEvent(new Event('click'));
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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">🔗</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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user