feat(globe): answer both reset buttons through the globe actions store

The top-center and cockpit reset buttons call the location controls'
new reset() through the globe actions store, and render a reset under
way from it, instead of the location controls binding their clicks and
the navigation writing their labels. The store keeps each owner's reset
under way like its clear, and controls replaced mid-reset keep it on the
buttons. LocationControls no longer takes resetButtons.
This commit is contained in:
Sameh Khamis committed 2026-11-01 13:43:57 -08:00
1 parent eccaeea0c4
commit b4ee2321cf
17 files changed
+173 -84

No files matched your search

+6 -5
View File
@@ -72,9 +72,9 @@
are the kit's new `UiChip`. The selects are the kit's new `UiSelect`, the
status badges its new `UiBadge` and the one-line empty states its new
`UiEmpty`.
The top-center clear, share, tilt and north-up buttons call the shell
through the new globe actions store, which also carries the camera's tilt
and compass heading. `syncScopeControls` (`./ui/display`),
The top-center clear, share, tilt, north-up and reset buttons, and the
cockpit's reset, call the shell through the new globe actions store,
which also carries the camera's tilt and compass heading. `syncScopeControls` (`./ui/display`),
`shouldHideCollapsedRightPanels` (`./ui/layout`) and
`bindClearLayersControl` (`./ui/layers`) are deprecated: the Display view,
the rail and the globe actions view now own what they did. `./ui/input` imports Vue
@@ -113,8 +113,9 @@
landmark pills or writes the mini status: it presents the tray, including
the search error (`setSearchError()`), and hands it to its `publish`
option, and Core's dock renders it and calls `chooseCity()` and
`choosePoi()`. Its `elements` are now only `search`, `searchToggle` and
`resetButtons`. `createOrbitIndicator()` is replaced by
`choosePoi()`. Its `elements` are now only `search` and `searchToggle`:
`resetButtons` is gone, and the reset buttons call its new `reset()`.
`createOrbitIndicator()` is replaced by
`showOrbit(active)`, which adds the ORBIT indicator to the presented tray
as `orbit: { active }` (null until offered) (samehkhamis).
+5 -4
View File
@@ -225,10 +225,11 @@ a feature supplying only its accent.
`splitFlap.js` plans, writing only the label's one text node; the toast
renders what each owner's `createToast()` publishes there, on that
owner's own timer, and the title bar's FPS readout what
`createFrameRateMonitor()` publishes), the globe actions (in progress:
`GlobeActions` renders the top-center bar; its clear, share, tilt and
north-up buttons call the shell through the `gev-globe-actions` store,
which also carries a clear under way and the camera's tilt and heading
`createFrameRateMonitor()` publishes), the globe actions
(`GlobeActions` renders the top-center bar; its clear, share, tilt,
north-up and reset buttons, and the cockpit's reset, call the shell
through the `gev-globe-actions` store, which also carries a clear or a
reset under way and the camera's tilt and heading
that `bindCameraOrientationControls` publishes each drawn frame it
changes, which only `GlobeCompass` renders; an owner publishes only
while it answers the button, so a shell replaced by a newer one leaves
+2 -2
View File
@@ -296,7 +296,7 @@ try {
'native reset returns through the existing camera handoff',
await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
ui._resetGlobeBtn.click();
document.getElementById('reset-globe-view').click();
const pending = ui._globeResetPromise;
if (!pending) return false;
await pending;
@@ -317,7 +317,7 @@ try {
.getElementById('location-pills')
.querySelector('button')
?.click();
ui._resetGlobeBtn.click();
document.getElementById('reset-globe-view').click();
ui._locationSearch.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
+13 -19
View File
@@ -795,35 +795,29 @@ test('Reset releases Contact camera ownership through its selection-preserving r
assert.ok(resetStart >= 0);
assert.ok(contextRelease > resetStart);
assert.ok(satelliteRelease > contextRelease);
assert.match(
readShellElements.toString(),
/_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/,
);
const controls = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'locationControls.js'),
'utf8',
);
assert.match(
ui,
/resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/,
);
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
assert.match(
controls,
/for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
'both reset controls delegate to the same supplied reset action',
assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/);
assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/);
// Both reset buttons ask the one reset owner.
const globeActions = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'),
'utf8',
);
assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/);
const cockpitView = readRadioSource(
new URL('./ui/views/CockpitView.vue', import.meta.url),
'utf8',
);
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/);
assert.match(
cockpitEnter.toString(),
/Object\.assign\(this\.presence, \{\s*active: true,/,
);
assert.match(
readRadioSource(
new URL('./ui/views/CockpitView.vue', import.meta.url),
'utf8',
),
/id="cockpit-reset-globe"[^>]*:hidden="!active"/,
);
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*:hidden="!active"/);
});
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
+30 -2
View File
@@ -322,14 +322,14 @@ export class StyleManager extends ShellFacade {
elements: {
_locationSearch: this._locationSearch,
_searchToggle: this._searchToggle,
_resetGlobeBtn: this._resetGlobeBtn,
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
},
operations: {
_publishLocation: (view) =>
useLocationStore(applicationPinia()).publish(view),
_connectLocation: (controls) =>
useLocationStore(applicationPinia()).connect(controls),
_connectGlobeReset: (controls) => this._connectGlobeReset(controls),
_publishGlobeResetting: (busy) => this._publishGlobeResetting(busy),
_beginDeferredNavigation: (...args) =>
this._beginDeferredNavigation(...args),
_reassertNavigationHandoff: (...args) =>
@@ -1649,6 +1649,32 @@ export class StyleManager extends ShellFacade {
);
}
/**
* Answer the globe and cockpit reset buttons through the location controls,
* or stop answering them (null).
*/
_connectGlobeReset(controls) {
const store = useGlobeActionsStore(applicationPinia());
if (this._resetToken) store.detach(this._resetToken);
this._resetToken = controls
? store.connect({ reset: () => controls.reset() })
: null;
// A reset under way as the controls are replaced stays on the buttons.
if (this._resetToken && this._globeResetting != null)
store.publishResetting(this._resetToken, this._globeResetting);
}
/** Publish a reset to the full globe view under way. */
_publishGlobeResetting(busy) {
if (this._disposed) return;
this._globeResetting = Boolean(busy);
if (this._resetToken)
useGlobeActionsStore(applicationPinia()).publishResetting(
this._resetToken,
busy,
);
}
/**
* Stop answering the globe actions. Its clear under way goes with its
* owner, and its camera state too; a newer shell's stays.
@@ -1659,7 +1685,9 @@ export class StyleManager extends ShellFacade {
if (this._clearLayersToken) store.detach(this._clearLayersToken);
if (this._shareToken) store.detach(this._shareToken);
if (this._orientationToken) store.detach(this._orientationToken);
if (this._resetToken) store.detach(this._resetToken);
this._clearLayersToken = this._shareToken = this._orientationToken = null;
this._resetToken = null;
}
/** Wire Google Maps-style tilt and north-up camera actions. */
-1
View File
@@ -97,7 +97,6 @@ export class CockpitViewController {
this.tr3bToggle = document.getElementById('tr3b-toggle');
this._tr3bSignature = null;
this.mapViewButton = document.getElementById('map-view-switch');
this.resetGlobeButton = document.getElementById('cockpit-reset-globe');
this.hud = document.getElementById('cockpit-hud');
this.entryFocusOrigin = null;
this.visionPrevious = document.getElementById('cockpit-vision-previous');
-2
View File
@@ -169,7 +169,6 @@ test('the ORBIT indicator sits on the body while offered and lights while orbiti
elements: {
search: $('#location-search'),
searchToggle: $('#search-toggle'),
resetButtons: [],
},
cities: {},
getExpandedCity: () => null,
@@ -211,7 +210,6 @@ test('the location tray renders what LocationControls publishes', async (t) => {
elements: {
search: $('#location-search'),
searchToggle: $('#search-toggle'),
resetButtons: [],
},
cities: {
paris: { name: 'Paris', pois: [{ name: 'Louvre' }, { name: 'Eiffel' }] },
+44 -1
View File
@@ -8,6 +8,7 @@ import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useGlobeActionsStore } from './stores/globeActions.js';
import GlobeActions from './views/panels/GlobeActions.vue';
import CockpitView from './views/CockpitView.vue';
function mount(t) {
document.body.replaceChildren();
@@ -19,6 +20,7 @@ function mount(t) {
share: () => calls.push('share'),
tilt: () => calls.push('tilt'),
northUp: () => calls.push('northUp'),
reset: () => calls.push('reset'),
});
const app = createApp(GlobeActions).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
@@ -33,7 +35,8 @@ test('every button calls its owner', (t) => {
$('share-btn').click();
$('tilt-map-view').click();
$('north-up-view').click();
assert.deepEqual(calls, ['clear', 'share', 'tilt', 'northUp']);
$('reset-globe-view').click();
assert.deepEqual(calls, ['clear', 'share', 'tilt', 'northUp', 'reset']);
});
test('the tilt and compass buttons show the published camera', async (t) => {
@@ -130,3 +133,43 @@ test('the same camera state again changes nothing', () => {
assert.notEqual(store.camera, kept);
assert.deepEqual(store.camera, { tilted: true, heading: 13 });
});
test('a reset under way relabels the reset button for its owner', async (t) => {
const { store, token } = mount(t);
const reset = $('reset-globe-view');
assert.equal(reset.getAttribute('aria-label'), 'Reset to full globe view');
store.publishResetting(token, true);
await nextTick();
assert.equal(
reset.getAttribute('aria-label'),
'Resetting to full globe view',
);
assert.equal(store.clearBusy, null, 'a reset is not a clear');
store.detach(token);
await nextTick();
assert.equal(reset.getAttribute('aria-label'), 'Reset to full globe view');
});
test('the cockpit reset asks the reset owner and shows its reset', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const store = useGlobeActionsStore(pinia);
const calls = [];
const token = store.connect({ reset: () => calls.push('reset') });
const app = createApp(CockpitView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const reset = $('cockpit-reset-globe');
assert.equal(
reset.getAttribute('aria-label'),
'Reset cockpit to full globe view',
);
reset.click();
assert.deepEqual(calls, ['reset']);
store.publishResetting(token, true);
await nextTick();
assert.equal(
reset.getAttribute('aria-label'),
'Resetting cockpit to full globe view',
);
});
+6 -3
View File
@@ -19,7 +19,8 @@ const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze(
* mini status, the search error and the ORBIT indicator are presented as a
* plain view and handed to `publish`; Core's dock renders them and calls
* `chooseCity()` and `choosePoi()`, and the scene chrome shows the indicator.
* The search field and reset buttons keep their listeners.
* The globe actions and the cockpit call `reset()`. The search field keeps its
* listeners.
*/
export class LocationControls {
constructor({
@@ -90,8 +91,6 @@ export class LocationControls {
this.bind(elements.search, 'blur', () => {
if (!elements.search.value) onSearchEdit?.();
});
for (const button of elements.resetButtons)
this.bind(button, 'click', onReset);
this.present();
}
bind(element, event, handler, removers = this.removers) {
@@ -132,6 +131,10 @@ export class LocationControls {
chooseCity(id) {
if (!this.destroyed) this.onCity(id);
}
/** A reset to the full globe view was asked for. */
reset() {
if (!this.destroyed) return this.onReset();
}
/** A landmark pill of the shown city was clicked. */
choosePoi(index) {
if (!this.destroyed && this.poiCity !== null)
+2 -3
View File
@@ -61,7 +61,6 @@ function fixture() {
const elements = {
search: node(),
searchToggle: node(),
resetButtons: [node(), node()],
};
const doc = node();
doc.createElement = node;
@@ -168,7 +167,7 @@ test('destruction revokes document, search, reset, city and POI actions and remo
f.elements.search.value = 'Place';
f.elements.search.fire('keydown', { key: 'Enter' });
f.doc.fire('keydown', { key: 'Q' });
f.elements.resetButtons[0].fire('click');
f.controls.reset();
f.frames.get(0)();
assert.deepEqual(f.calls, []);
assert.equal(f.views.length, published);
@@ -187,7 +186,7 @@ test('location and POI keys route once while form controls retain typing', () =>
const f = fixture();
f.doc.fire('keydown', { key: 'W', target: { matches: () => false } });
f.doc.fire('keydown', { key: 'Q', target: { matches: () => true } });
f.elements.resetButtons[1].fire('click');
f.controls.reset();
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
});
test('typing clears a failed search error and closing the emptied field drops it too', () => {
+4 -17
View File
@@ -144,7 +144,6 @@ export class LocationNavigation {
elements: {
search: this._locationSearch,
searchToggle: this._searchToggle,
resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
},
publish: (view) => this._publishLocation?.(view),
cities: CITY_POIS,
@@ -156,6 +155,7 @@ export class LocationNavigation {
onReset: () => this.resetToGlobeView(),
});
this._connectLocation?.(this._locationControls);
this._connectGlobeReset?.(this._locationControls);
}
_beginWorldJumpTransition() {
@@ -390,14 +390,7 @@ export class LocationNavigation {
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(2)),
},
};
this._resetGlobeBtn?.setAttribute(
'aria-label',
t('Reset to full globe view'),
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
t('Reset cockpit to full globe view'),
);
this._publishGlobeResetting?.(false);
this._globeResetPromise = null;
this._cancelGlobeReset = null;
resolveReset(result);
@@ -410,14 +403,7 @@ export class LocationNavigation {
Math.abs(height - GLOBE_VIEW.heightM) > 1000,
);
}, 4200);
this._resetGlobeBtn?.setAttribute(
'aria-label',
t('Resetting to full globe view'),
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
t('Resetting cockpit to full globe view'),
);
this._publishGlobeResetting?.(true);
const target = flyToGlobeView(this.viewer, {
onComplete: () => finish(false),
onCancel: () => finish(true),
@@ -436,6 +422,7 @@ export class LocationNavigation {
this._locationLookup?.destroy();
this._locationControls?.destroy();
this._connectLocation?.(null);
this._connectGlobeReset?.(null);
this._publishLocation?.(null);
this._cancelGlobeReset?.();
this._cancelGlobeReset = null;
-1
View File
@@ -54,7 +54,6 @@ test('ru: location pills and the mini-status show the translated city', () => {
const elements = {
search: element(),
searchToggle: element(),
resetButtons: [],
};
const doc = { ...element(), createElement: element };
const cities = { tokyo: CITY_POIS.tokyo };
+23
View File
@@ -300,3 +300,26 @@ test('an older shell that answers again shows its clear and its unchanged camera
older._cameraOrientationControls.destroy();
StyleManager.prototype._releaseGlobeActions.call(older);
});
test('the location controls answer both reset buttons until they go', () => {
const store = useGlobeActionsStore(applicationPinia());
const calls = [];
const owner = { _disposed: false };
const controls = { reset: () => calls.push('reset') };
StyleManager.prototype._connectGlobeReset.call(owner, controls);
store.act('reset');
StyleManager.prototype._publishGlobeResetting.call(owner, true);
assert.equal(store.resetBusy, true);
// Controls replaced mid-reset keep the reset on the buttons.
StyleManager.prototype._connectGlobeReset.call(owner, controls);
assert.equal(store.resetBusy, true);
StyleManager.prototype._publishGlobeResetting.call(owner, false);
assert.equal(store.resetBusy, false);
StyleManager.prototype._connectGlobeReset.call(owner, null);
store.act('reset');
assert.deepEqual(calls, ['reset']);
assert.equal(store.resetBusy, null);
// A reset finishing after the controls went changes nothing.
StyleManager.prototype._publishGlobeResetting.call(owner, false);
assert.equal(store.resetBusy, null);
});
-2
View File
@@ -63,8 +63,6 @@ export function readShellElements(document = globalThis.document) {
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
_cctvMeta: document.getElementById('cctv-meta'),
_cctvSummary: document.getElementById('cctv-summary'),
_resetGlobeBtn: document.getElementById('reset-globe-view'),
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
_locationSearch: document.getElementById('location-search'),
_searchToggle: document.getElementById('search-toggle'),
_safeFrameOverlay: document.getElementById('safe-frame-overlay'),
+32 -19
View File
@@ -3,33 +3,43 @@ import { computed, ref, shallowReactive } from 'vue';
import { createOwners } from './owners.js';
/**
* The top-center globe actions as their view renders them. The shell
* publishes whether a clear of the selected data layers is under way and the
* camera's tilt and heading, and answers the buttons through act().
* The top-center globe actions as their view renders them, and the cockpit's
* reset. The shell publishes whether a clear of the selected data layers or a
* reset to the full globe view is under way and the camera's tilt and
* heading, and answers the buttons through act().
*/
export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
const owners = createOwners();
// Who answers which action, as the computed state below reads it.
const ownership = ref(0);
/** Each clear owner's clear under way, by its token. */
const busyBy = shallowReactive(new Map());
/**
* Whether the clear owner's clear is under way; null until it says. Each
* owner keeps its own, so the button shows the one that answers it.
* Whether the work behind an action is under way, kept per owner so its
* button shows the owner that answers it; null until that owner says.
*/
const clearBusy = computed(() => {
void ownership.value;
return busyBy.get(owners.ownerOf('clear')) ?? null;
});
/**
* @param {object} token The clear owner's, from connect().
* @param {boolean} busy
*/
function publishClearBusy(token, busy) {
busyBy.set(token, Boolean(busy));
function ownedBusy(name) {
const byOwner = shallowReactive(new Map());
const busy = computed(() => {
void ownership.value;
return byOwner.get(owners.ownerOf(name)) ?? null;
});
/**
* @param {object} token The owner's, from connect().
* @param {boolean} value
*/
const publish = (token, value) => {
byOwner.set(token, Boolean(value));
};
return { byOwner, busy, publish };
}
const clearing = ownedBusy('clear');
const resetting = ownedBusy('reset');
/** Whether a clear of the selected data layers is under way. */
const clearBusy = clearing.busy;
const publishClearBusy = clearing.publish;
/** Whether a reset to the full globe view is under way. */
const resetBusy = resetting.busy;
const publishResetting = resetting.publish;
/**
* Whether the map reads as tilted and the camera's whole-degree heading;
@@ -63,7 +73,8 @@ export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
function detach(token) {
owners.detach(token);
busyBy.delete(token);
clearing.byOwner.delete(token);
resetting.byOwner.delete(token);
ownership.value += 1;
}
@@ -72,6 +83,8 @@ export const useGlobeActionsStore = defineStore('gev-globe-actions', () => {
return {
clearBusy,
publishClearBusy,
resetBusy,
publishResetting,
camera,
publishCamera,
connect,
+4 -1
View File
@@ -9,6 +9,7 @@ import {
} from '../cockpitInstrumentState.js';
import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js';
import { useGlobeActionsStore } from '../stores/globeActions.js';
import CockpitBriefing from './panels/CockpitBriefing.vue';
import CockpitContext from './panels/CockpitContext.vue';
import CockpitRollingValue from './panels/CockpitRollingValue.vue';
@@ -21,6 +22,8 @@ import CockpitUtilityControls from './panels/CockpitUtilityControls.vue';
// keep its listeners.
const { presence, instruments, route, vision } = storeToRefs(useCockpitStore());
const active = computed(() => Boolean(presence.value?.active));
const globe = useGlobeActionsStore();
const { resetBusy } = storeToRefs(globe);
const initialInstruments = createInstrumentState();
const initialRoute = createRouteState();
const initialVision = createVisionState();
@@ -188,7 +191,7 @@ const tickStyle = (tick) =>
</section>
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label>
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" :hidden="!active" data-i18n-aria-label data-i18n-title>
<button id="cockpit-reset-globe" type="button" :aria-label="resetBusy ? t('Resetting cockpit to full globe view') : t('Reset cockpit to full globe view')" title="Exit cockpit and return to full globe view" :hidden="!active" data-i18n-title @click="globe.act('reset')">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span data-i18n>RESET</span>
</button>
+2 -2
View File
@@ -11,7 +11,7 @@ import GlobeCompass from './GlobeCompass.vue';
// 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, camera } = storeToRefs(globe);
const { clearBusy, resetBusy, camera } = storeToRefs(globe);
const { act } = globe;
const tilted = computed(() => Boolean(camera.value?.tilted));
</script>
@@ -28,7 +28,7 @@ const tilted = computed(() => Boolean(camera.value?.tilted));
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</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>
<button id="reset-globe-view" type="button" :aria-label="resetBusy ? t('Resetting to full globe view') : t('Reset to full globe view')" title="Reset camera and return to full globe view" data-i18n-title @click="act('reset')">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>