mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
+495
-180
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
|
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
|
status badges its new `UiBadge` and the one-line empty states its new
|
||||||
`UiEmpty`.
|
`UiEmpty`.
|
||||||
The top-center clear and share buttons call the shell through the new
|
The top-center clear, share, tilt and north-up buttons call the shell
|
||||||
globe actions store. `syncScopeControls` (`./ui/display`),
|
through the new globe actions store, which also carries the camera's tilt
|
||||||
|
and compass heading. `syncScopeControls` (`./ui/display`),
|
||||||
`shouldHideCollapsedRightPanels` (`./ui/layout`) and
|
`shouldHideCollapsedRightPanels` (`./ui/layout`) and
|
||||||
`bindClearLayersControl` (`./ui/layers`) are deprecated: the Display view,
|
`bindClearLayersControl` (`./ui/layers`) are deprecated: the Display view,
|
||||||
the rail and the globe actions view now own what they did. `./ui/input` imports Vue
|
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
|
renders what each owner's `createToast()` publishes there, on that
|
||||||
owner's own timer, and the title bar's FPS readout what
|
owner's own timer, and the title bar's FPS readout what
|
||||||
`createFrameRateMonitor()` publishes), the globe actions (in progress:
|
`createFrameRateMonitor()` publishes), the globe actions (in progress:
|
||||||
`GlobeActions` renders the top-center bar; its clear and share buttons
|
`GlobeActions` renders the top-center bar; its clear, share, tilt and
|
||||||
call the shell through the `gev-globe-actions` store, which also carries
|
north-up buttons call the shell through the `gev-globe-actions` store,
|
||||||
a clear under way), the Scene actions panel (`SceneInteractions`
|
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
|
teleports what the scene interactions owner publishes to the
|
||||||
`gev-scene-interactions` store into the viewer's container; the owner
|
`gev-scene-interactions` store into the viewer's container; the owner
|
||||||
keeps the pick handler and focuses the picked action), the data-pack
|
keeps the pick handler and focuses the picked action), the data-pack
|
||||||
|
|||||||
@@ -140,7 +140,8 @@
|
|||||||
"src/ui/kit/UiEmpty.vue",
|
"src/ui/kit/UiEmpty.vue",
|
||||||
"src/ui/kit/UiBadge.vue",
|
"src/ui/kit/UiBadge.vue",
|
||||||
"src/ui/stores/globeActions.js",
|
"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"]
|
"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\._displayBindings\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._visualSettings\.stop\(\)/);
|
assert.match(synchronous, /this\._visualSettings\.stop\(\)/);
|
||||||
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /globeActions\.detach\(this\._clearLayersToken\)/);
|
assert.match(synchronous, /this\._releaseGlobeActions\(\)/);
|
||||||
assert.match(synchronous, /globeActions\.detach\(this\._shareToken\)/);
|
|
||||||
assert.match(synchronous, /this\._locationNavigation\.destroy\(\)/);
|
assert.match(synchronous, /this\._locationNavigation\.destroy\(\)/);
|
||||||
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationControls\?\.destroy\(\)/);
|
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationControls\?\.destroy\(\)/);
|
||||||
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/);
|
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/);
|
||||||
|
|||||||
+49
-14
@@ -1008,10 +1008,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
showToast: (message) => {
|
showToast: (message) => {
|
||||||
if (!this._disposed) this._showToast(message);
|
if (!this._disposed) this._showToast(message);
|
||||||
},
|
},
|
||||||
setClearBusy: (busy) => {
|
setClearBusy: (busy) => this._publishClearBusy(busy),
|
||||||
if (!this._disposed)
|
|
||||||
useGlobeActionsStore(applicationPinia()).publishClearBusy(busy);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// The Contacts layer publishes its panel; the view calls its navigation.
|
// 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();
|
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. */
|
/** Wire Google Maps-style tilt and north-up camera actions. */
|
||||||
_initCameraOrientationControls() {
|
_initCameraOrientationControls() {
|
||||||
|
const store = useGlobeActionsStore(applicationPinia());
|
||||||
this._cameraOrientationControls?.destroy();
|
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,
|
viewer: this.viewer,
|
||||||
elements: {
|
// Every drawn frame publishes: a state the store already shows stops
|
||||||
tiltButton: this._tiltMapBtn,
|
// here, before the store's actions run.
|
||||||
northButton: this._northUpBtn,
|
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) =>
|
runNavigation: (noun, navigate) =>
|
||||||
this._navigation.runOrientation(noun, navigate),
|
this._navigation.runOrientation(noun, navigate),
|
||||||
showToast: (message) => this._showToast(message),
|
showToast: (message) => this._showToast(message),
|
||||||
});
|
});
|
||||||
|
this._cameraOrientationControls = controls;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Share Button ─────────────────────────────
|
// ── Share Button ─────────────────────────────
|
||||||
@@ -1790,12 +1830,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
useDisplayStore(applicationPinia()).connectMapSource(null);
|
useDisplayStore(applicationPinia()).connectMapSource(null);
|
||||||
useDisplayStore(applicationPinia()).publishMapSource(null);
|
useDisplayStore(applicationPinia()).publishMapSource(null);
|
||||||
this._cameraOrientationControls?.destroy();
|
this._cameraOrientationControls?.destroy();
|
||||||
const globeActions = useGlobeActionsStore(applicationPinia());
|
this._releaseGlobeActions();
|
||||||
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._cctvControls?.destroy();
|
this._cctvControls?.destroy();
|
||||||
this.services?.streetLevelLayer?.attachNavigation?.(null);
|
this.services?.streetLevelLayer?.attachNavigation?.(null);
|
||||||
this._streetLevelControls?.destroy();
|
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({
|
export function bindCameraOrientationControls({
|
||||||
viewer,
|
viewer,
|
||||||
elements,
|
publish,
|
||||||
runNavigation,
|
runNavigation,
|
||||||
showToast,
|
showToast,
|
||||||
}) {
|
}) {
|
||||||
const tiltButton = elements?.tiltButton;
|
|
||||||
const northButton = elements?.northButton;
|
|
||||||
const removers = [];
|
const removers = [];
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let applied = null;
|
|
||||||
// The tilt state is the one thing here that costs a depth read of the middle
|
// 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
|
// 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.
|
// 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
|
* 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
|
* 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),
|
* 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.
|
* 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() {
|
function sync() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
@@ -278,40 +282,11 @@ export function bindCameraOrientationControls({
|
|||||||
tilted,
|
tilted,
|
||||||
heading: Math.round(headingDegrees(viewer?.camera)) % 360,
|
heading: Math.round(headingDegrees(viewer?.camera)) % 360,
|
||||||
};
|
};
|
||||||
if (
|
publish?.(next);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const listen = (element, handler) => {
|
function tilt() {
|
||||||
if (!element) return;
|
if (destroyed) return;
|
||||||
element.addEventListener('click', handler);
|
|
||||||
removers.push(() => element.removeEventListener('click', handler));
|
|
||||||
};
|
|
||||||
listen(tiltButton, () => {
|
|
||||||
const pending = animator.destination;
|
const pending = animator.destination;
|
||||||
const result = runNavigation('camera', () => {
|
const result = runNavigation('camera', () => {
|
||||||
const frame = readCameraTargetFrame(viewer);
|
const frame = readCameraTargetFrame(viewer);
|
||||||
@@ -332,8 +307,10 @@ export function bindCameraOrientationControls({
|
|||||||
tilted = result.tilted;
|
tilted = result.tilted;
|
||||||
}
|
}
|
||||||
sync();
|
sync();
|
||||||
});
|
}
|
||||||
listen(northButton, () => {
|
|
||||||
|
function northUp() {
|
||||||
|
if (destroyed) return;
|
||||||
const pending = animator.destination;
|
const pending = animator.destination;
|
||||||
const result = runNavigation('camera', () => {
|
const result = runNavigation('camera', () => {
|
||||||
const frame = readCameraTargetFrame(viewer);
|
const frame = readCameraTargetFrame(viewer);
|
||||||
@@ -347,7 +324,7 @@ export function bindCameraOrientationControls({
|
|||||||
});
|
});
|
||||||
if (result) showToast?.(t('North up'));
|
if (result) showToast?.(t('North up'));
|
||||||
sync();
|
sync();
|
||||||
});
|
}
|
||||||
|
|
||||||
const subscribe = (event, handler) => {
|
const subscribe = (event, handler) => {
|
||||||
const remove = event?.addEventListener?.(handler);
|
const remove = event?.addEventListener?.(handler);
|
||||||
@@ -369,6 +346,8 @@ export function bindCameraOrientationControls({
|
|||||||
refreshTilt();
|
refreshTilt();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
tilt,
|
||||||
|
northUp,
|
||||||
sync,
|
sync,
|
||||||
refreshTilt,
|
refreshTilt,
|
||||||
cancel: animator.cancel,
|
cancel: animator.cancel,
|
||||||
|
|||||||
@@ -49,23 +49,25 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
|
|||||||
return { viewer, calls, target: surface };
|
return { viewer, calls, target: surface };
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeButton extends EventTarget {
|
/**
|
||||||
constructor() {
|
* Record what the controls publish as the globe actions store keeps it:
|
||||||
super();
|
* `state` is the latest, and `publishes` counts the changes.
|
||||||
this.attributes = new Map();
|
*/
|
||||||
this.style = {
|
function recorder() {
|
||||||
setProperty: (name, value) => this.attributes.set(name, value),
|
const bar = {
|
||||||
};
|
state: null,
|
||||||
}
|
publishes: 0,
|
||||||
setAttribute(name, value) {
|
publish(state) {
|
||||||
this.attributes.set(name, String(value));
|
if (
|
||||||
}
|
bar.state?.tilted === state.tilted &&
|
||||||
getAttribute(name) {
|
bar.state?.heading === state.heading
|
||||||
return this.attributes.get(name);
|
)
|
||||||
}
|
return;
|
||||||
click() {
|
bar.state = state;
|
||||||
this.dispatchEvent(new Event('click'));
|
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]) {
|
for (const range of [3_000, 18_000_000]) {
|
||||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||||
orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range);
|
orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range);
|
||||||
const tiltButton = new FakeButton();
|
const bar = recorder();
|
||||||
const controls = bindCameraOrientationControls({
|
const controls = bindCameraOrientationControls({
|
||||||
viewer,
|
viewer,
|
||||||
elements: { tiltButton, northButton: new FakeButton() },
|
publish: bar.publish,
|
||||||
runNavigation: (_noun, navigate) => navigate(),
|
runNavigation: (_noun, navigate) => navigate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
tiltButton.getAttribute('aria-pressed'),
|
bar.state.tilted,
|
||||||
'false',
|
false,
|
||||||
`straight down at ${range} m must not read as tilted`,
|
`straight down at ${range} m must not read as tilted`,
|
||||||
);
|
);
|
||||||
tiltButton.click();
|
controls.tilt();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
tiltButton.getAttribute('aria-pressed'),
|
bar.state.tilted,
|
||||||
'true',
|
true,
|
||||||
`after one click at ${range} m the button must read tilted`,
|
`after one click at ${range} m the button must read tilted`,
|
||||||
);
|
);
|
||||||
// And the exact refresh that runs when the camera settles must agree.
|
// And the exact refresh that runs when the camera settles must agree.
|
||||||
controls.refreshTilt();
|
controls.refreshTilt();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
tiltButton.getAttribute('aria-pressed'),
|
bar.state.tilted,
|
||||||
'true',
|
true,
|
||||||
`the settled state at ${range} m must agree with the commanded one`,
|
`the settled state at ${range} m must agree with the commanded one`,
|
||||||
);
|
);
|
||||||
controls.destroy();
|
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', () => {
|
test('the needle follows a rotation too small for the camera-changed threshold', () => {
|
||||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||||
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
|
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
|
||||||
const northButton = new FakeButton();
|
const bar = recorder();
|
||||||
const controls = bindCameraOrientationControls({
|
const controls = bindCameraOrientationControls({
|
||||||
viewer,
|
viewer,
|
||||||
elements: { tiltButton: new FakeButton(), northButton },
|
publish: bar.publish,
|
||||||
runNavigation: (_noun, navigate) => navigate(),
|
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
|
// Ten degrees is far below Cesium's default percentageChanged, so
|
||||||
// `camera.changed` may never fire for it; the per-frame signal must.
|
// `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);
|
orbit(viewer, AUSTIN_GROUND, 10, Cesium.Math.toRadians(-80), 5_000);
|
||||||
viewer.scene.preRender.raiseEvent();
|
viewer.scene.preRender.raiseEvent();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
northButton.getAttribute('--camera-heading'),
|
bar.state.heading,
|
||||||
'10deg',
|
10,
|
||||||
`needle must follow a 10 degree rotation (camera.changed fired ${changedFired} time(s))`,
|
`needle must follow a 10 degree rotation (camera.changed fired ${changedFired} time(s))`,
|
||||||
);
|
);
|
||||||
|
|
||||||
orbit(viewer, AUSTIN_GROUND, 13, Cesium.Math.toRadians(-80), 5_000);
|
orbit(viewer, AUSTIN_GROUND, 13, Cesium.Math.toRadians(-80), 5_000);
|
||||||
viewer.scene.preRender.raiseEvent();
|
viewer.scene.preRender.raiseEvent();
|
||||||
assert.equal(northButton.getAttribute('--camera-heading'), '13deg');
|
assert.equal(bar.state.heading, 13);
|
||||||
controls.destroy();
|
controls.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('north-up indicator follows real heading including the 360-degree seam', () => {
|
test('north-up indicator follows real heading including the 360-degree seam', () => {
|
||||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||||
const northButton = new FakeButton();
|
const bar = recorder();
|
||||||
const controls = bindCameraOrientationControls({
|
const controls = bindCameraOrientationControls({
|
||||||
viewer,
|
viewer,
|
||||||
elements: { northButton },
|
publish: bar.publish,
|
||||||
runNavigation: (_noun, navigate) => navigate(),
|
runNavigation: (_noun, navigate) => navigate(),
|
||||||
});
|
});
|
||||||
for (const [heading, active] of [
|
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);
|
orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000);
|
||||||
viewer.scene.preRender.raiseEvent();
|
viewer.scene.preRender.raiseEvent();
|
||||||
assert.equal(
|
assert.equal(bar.state.heading === 0, active, `heading ${heading}`);
|
||||||
northButton.getAttribute('data-north-up'),
|
|
||||||
String(active),
|
|
||||||
`heading ${heading}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
controls.destroy();
|
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);
|
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||||
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
|
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
|
||||||
const tiltButton = new FakeButton();
|
const bar = recorder();
|
||||||
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 preRenderBefore = viewer.scene.preRender.numberOfListeners;
|
const preRenderBefore = viewer.scene.preRender.numberOfListeners;
|
||||||
const moveEndBefore = viewer.camera.moveEnd.numberOfListeners;
|
const moveEndBefore = viewer.camera.moveEnd.numberOfListeners;
|
||||||
const controls = bindCameraOrientationControls({
|
const controls = bindCameraOrientationControls({
|
||||||
viewer,
|
viewer,
|
||||||
elements: { tiltButton, northButton },
|
publish: bar.publish,
|
||||||
runNavigation: (_noun, navigate) => navigate(),
|
runNavigation: (_noun, navigate) => navigate(),
|
||||||
});
|
});
|
||||||
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore + 1);
|
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore + 1);
|
||||||
assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore + 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();
|
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);
|
orbit(viewer, AUSTIN_GROUND, 47, Cesium.Math.toRadians(-80), 5_000);
|
||||||
viewer.scene.preRender.raiseEvent();
|
viewer.scene.preRender.raiseEvent();
|
||||||
assert.ok(writes > 0, 'a rotated camera updates the needle');
|
assert.ok(bar.publishes > 0, 'a rotated camera updates the needle');
|
||||||
writes = 0;
|
bar.publishes = 0;
|
||||||
for (let i = 0; i < 20; i += 1) viewer.scene.preRender.raiseEvent();
|
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();
|
controls.destroy();
|
||||||
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore);
|
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore);
|
||||||
assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore);
|
assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore);
|
||||||
writes = 0;
|
bar.publishes = 0;
|
||||||
orbit(viewer, AUSTIN_GROUND, 120, Cesium.Math.toRadians(-80), 5_000);
|
orbit(viewer, AUSTIN_GROUND, 120, Cesium.Math.toRadians(-80), 5_000);
|
||||||
viewer.scene.preRender.raiseEvent();
|
viewer.scene.preRender.raiseEvent();
|
||||||
viewer.camera.moveEnd.raiseEvent();
|
viewer.camera.moveEnd.raiseEvent();
|
||||||
tiltButton.click();
|
controls.tilt();
|
||||||
assert.equal(writes, 0, 'a destroyed binding writes nothing');
|
assert.equal(bar.publishes, 0, 'a destroyed binding publishes nothing');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a sky-facing camera yields no frame and both actions decline', () => {
|
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', () => {
|
test('bindings route both controls and release every listener on destroy', () => {
|
||||||
const { viewer } = createViewer();
|
const { viewer } = createViewer();
|
||||||
const tiltButton = new FakeButton();
|
const bar = recorder();
|
||||||
const northButton = new FakeButton();
|
|
||||||
const navigations = [];
|
const navigations = [];
|
||||||
const toasts = [];
|
const toasts = [];
|
||||||
const controls = bindCameraOrientationControls({
|
const controls = bindCameraOrientationControls({
|
||||||
viewer,
|
viewer,
|
||||||
elements: { tiltButton, northButton },
|
publish: bar.publish,
|
||||||
runNavigation(noun, navigate) {
|
runNavigation(noun, navigate) {
|
||||||
navigations.push(noun);
|
navigations.push(noun);
|
||||||
return navigate();
|
return navigate();
|
||||||
@@ -501,20 +485,20 @@ test('bindings route both controls and release every listener on destroy', () =>
|
|||||||
showToast: (message) => toasts.push(message),
|
showToast: (message) => toasts.push(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
tiltButton.click();
|
controls.tilt();
|
||||||
northButton.click();
|
controls.northUp();
|
||||||
assert.deepEqual(navigations, ['camera', 'camera']);
|
assert.deepEqual(navigations, ['camera', 'camera']);
|
||||||
assert.deepEqual(toasts, ['Tilted view', 'North up']);
|
assert.deepEqual(toasts, ['Tilted view', 'North up']);
|
||||||
// The button shows the state the click just commanded, not a re-measurement.
|
// The button shows the state the click just commanded, not a re-measurement.
|
||||||
assert.equal(tiltButton.getAttribute('aria-pressed'), 'true');
|
assert.equal(bar.state.tilted, true);
|
||||||
assert.equal(northButton.getAttribute('--camera-heading'), '90deg');
|
assert.equal(bar.state.heading, 90);
|
||||||
|
|
||||||
viewer.camera.heading = -1e-12;
|
viewer.camera.heading = -1e-12;
|
||||||
controls.sync();
|
controls.sync();
|
||||||
assert.equal(northButton.getAttribute('--camera-heading'), '0deg');
|
assert.equal(bar.state.heading, 0);
|
||||||
|
|
||||||
controls.destroy();
|
controls.destroy();
|
||||||
tiltButton.click();
|
controls.tilt();
|
||||||
assert.deepEqual(navigations, ['camera', 'camera']);
|
assert.deepEqual(navigations, ['camera', 'camera']);
|
||||||
assert.equal(STRAIGHT_DOWN_PITCH, Cesium.Math.toRadians(-89));
|
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);
|
const { viewer } = createRealViewer(target);
|
||||||
orbit(viewer, target, 90, OBLIQUE_PITCH, 1000);
|
orbit(viewer, target, 90, OBLIQUE_PITCH, 1000);
|
||||||
viewer.scene.preUpdate = new Cesium.Event();
|
viewer.scene.preUpdate = new Cesium.Event();
|
||||||
const tiltButton = new FakeButton();
|
const bar = recorder();
|
||||||
const northButton = new FakeButton();
|
|
||||||
let controls;
|
let controls;
|
||||||
controls = bindCameraOrientationControls({
|
controls = bindCameraOrientationControls({
|
||||||
viewer,
|
viewer,
|
||||||
elements: { tiltButton, northButton },
|
publish: bar.publish,
|
||||||
runNavigation: (_noun, navigate) => {
|
runNavigation: (_noun, navigate) => {
|
||||||
controls.cancel();
|
controls.cancel();
|
||||||
return navigate();
|
return navigate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
tiltButton.click();
|
controls.tilt();
|
||||||
tiltButton.click();
|
controls.tilt();
|
||||||
assert.equal(tiltButton.getAttribute('aria-pressed'), 'true');
|
assert.equal(bar.state.tilted, true);
|
||||||
tiltButton.click();
|
controls.tilt();
|
||||||
northButton.click();
|
controls.northUp();
|
||||||
assert.equal(viewer.scene.preUpdate.numberOfListeners, 1);
|
assert.equal(viewer.scene.preUpdate.numberOfListeners, 1);
|
||||||
time = 650;
|
time = 650;
|
||||||
viewer.scene.preUpdate.raiseEvent();
|
viewer.scene.preUpdate.raiseEvent();
|
||||||
|
|||||||
@@ -14,30 +14,71 @@ function mount(t) {
|
|||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const store = useGlobeActionsStore(pinia);
|
const store = useGlobeActionsStore(pinia);
|
||||||
const calls = [];
|
const calls = [];
|
||||||
store.connect({
|
const token = store.connect({
|
||||||
clear: () => calls.push('clear'),
|
clear: () => calls.push('clear'),
|
||||||
share: () => calls.push('share'),
|
share: () => calls.push('share'),
|
||||||
|
tilt: () => calls.push('tilt'),
|
||||||
|
northUp: () => calls.push('northUp'),
|
||||||
});
|
});
|
||||||
const app = createApp(GlobeActions).use(pinia);
|
const app = createApp(GlobeActions).use(pinia);
|
||||||
app.mount(document.body.appendChild(document.createElement('div')));
|
app.mount(document.body.appendChild(document.createElement('div')));
|
||||||
t.after(() => app.unmount());
|
t.after(() => app.unmount());
|
||||||
return { calls, store };
|
return { calls, store, token };
|
||||||
}
|
}
|
||||||
const $ = (id) => document.getElementById(id);
|
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);
|
const { calls } = mount(t);
|
||||||
$('clear-selected-layers').click();
|
$('clear-selected-layers').click();
|
||||||
$('share-btn').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) => {
|
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');
|
const clear = $('clear-selected-layers');
|
||||||
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
||||||
assert.equal(clear.hasAttribute('aria-busy'), false);
|
assert.equal(clear.hasAttribute('aria-busy'), false);
|
||||||
store.publishClearBusy(true);
|
store.publishClearBusy(token, true);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal(clear.getAttribute('aria-busy'), 'true');
|
assert.equal(clear.getAttribute('aria-busy'), 'true');
|
||||||
assert.equal(clear.getAttribute('aria-disabled'), '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',
|
'Clearing selected data layers',
|
||||||
);
|
);
|
||||||
assert.equal(clear.disabled, false);
|
assert.equal(clear.disabled, false);
|
||||||
store.publishClearBusy(false);
|
store.publishClearBusy(token, false);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal(clear.getAttribute('aria-busy'), 'false');
|
assert.equal(clear.getAttribute('aria-busy'), 'false');
|
||||||
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
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.
|
// A shell disposed mid-clear hands the next one a bar that is not busy.
|
||||||
store.publishClearBusy(true);
|
store.detach(token);
|
||||||
store.publishClearBusy(null);
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal(clear.hasAttribute('aria-busy'), false);
|
assert.equal(clear.hasAttribute('aria-busy'), false);
|
||||||
assert.equal(clear.hasAttribute('aria-disabled'), false);
|
assert.equal(clear.hasAttribute('aria-disabled'), false);
|
||||||
@@ -67,3 +119,14 @@ test('a detached owner no longer answers', (t) => {
|
|||||||
$('share-btn').click();
|
$('share-btn').click();
|
||||||
assert.deepEqual(calls, ['newer share', 'share']);
|
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 calls = [];
|
||||||
const owner = {
|
const owner = {
|
||||||
_disposed: false,
|
_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('share');
|
||||||
store.act('clear');
|
store.act('clear');
|
||||||
assert.deepEqual(calls, ['share', '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._shareToken);
|
||||||
store.detach(owner._clearLayersToken);
|
store.detach(owner._clearLayersToken);
|
||||||
store.act('share');
|
store.act('share');
|
||||||
store.act('clear');
|
store.act('clear');
|
||||||
assert.deepEqual(calls, ['share', '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'),
|
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
|
||||||
_cctvMeta: document.getElementById('cctv-meta'),
|
_cctvMeta: document.getElementById('cctv-meta'),
|
||||||
_cctvSummary: document.getElementById('cctv-summary'),
|
_cctvSummary: document.getElementById('cctv-summary'),
|
||||||
_tiltMapBtn: document.getElementById('tilt-map-view'),
|
|
||||||
_northUpBtn: document.getElementById('north-up-view'),
|
|
||||||
_resetGlobeBtn: document.getElementById('reset-globe-view'),
|
_resetGlobeBtn: document.getElementById('reset-globe-view'),
|
||||||
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
|
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
|
||||||
_locationSearch: document.getElementById('location-search'),
|
_locationSearch: document.getElementById('location-search'),
|
||||||
|
|||||||
@@ -1,22 +1,82 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { computed, ref, shallowReactive } from 'vue';
|
||||||
import { createOwners } from './owners.js';
|
import { createOwners } from './owners.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top-center globe actions as their view renders them. The shell
|
* 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
|
* publishes whether a clear of the selected data layers is under way and the
|
||||||
* answers the clear and share buttons through act().
|
* camera's tilt and heading, and answers the buttons through act().
|
||||||
*/
|
*/
|
||||||
export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
|
export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
|
||||||
/** Whether a clear is under way; null until the shell first says. */
|
const owners = createOwners();
|
||||||
const clearBusy = ref(null);
|
// 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) {
|
* Whether the clear owner's clear is under way; null until it says. Each
|
||||||
clearBusy.value = busy == null ? null : Boolean(busy);
|
* 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
|
* 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
|
* owner that connects again replaces itself, and owners of different actions
|
||||||
* sit side by side.
|
* 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() {
|
export function createOwners() {
|
||||||
/** @type {Array<{actions: object}>} Oldest first. */
|
/** @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('pick', 'jazz'), 'kept jazz');
|
||||||
assert.equal(act('missing'), undefined);
|
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>
|
<script setup>
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { t } from '../../../i18n/index.js';
|
import { t } from '../../../i18n/index.js';
|
||||||
import { useGlobeActionsStore } from '../../stores/globeActions.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
|
// The top-center camera and share actions. The shell answers the buttons
|
||||||
// share buttons through the store and publishes a clear under way, which
|
// through the store and publishes a clear under way, which keeps the button
|
||||||
// keeps the button focusable and marks it busy.
|
// 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 globe = useGlobeActionsStore();
|
||||||
const { clearBusy } = storeToRefs(globe);
|
const { clearBusy, camera } = storeToRefs(globe);
|
||||||
const { act } = globe;
|
const { act } = globe;
|
||||||
|
const tilted = computed(() => Boolean(camera.value?.tilted));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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')">
|
<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>
|
<span class="share-icon" aria-hidden="true">🔗</span>
|
||||||
</button>
|
</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>
|
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</span>
|
||||||
</button>
|
</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>
|
<GlobeCompass @click="act('northUp')" />
|
||||||
<span class="camera-compass-n" aria-hidden="true">N</span>
|
|
||||||
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
|
|
||||||
</button>
|
|
||||||
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view" data-i18n-aria-label data-i18n-title>
|
<button id="reset-globe-view" type="button" aria-label="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>
|
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||||
</button>
|
</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