mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): render the ORBIT indicator from the location tray
LocationControls offers the indicator with showOrbit(active) in its presented tray instead of building it on the body, and the scene chrome teleports it there. Orbit toggles, including the voice switch, light it through the controls.
This commit is contained in:
1 parent
f846aaed4c
commit
6398103b60
11 files changed
+120
-42
No files matched your search
+3
-1
@@ -73,7 +73,9 @@
|
||||
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` (samehkhamis).
|
||||
`resetButtons`. `createOrbitIndicator()` is replaced by
|
||||
`showOrbit(active)`, which adds the ORBIT indicator to the presented tray
|
||||
as `orbit: { active }` (null until offered) (samehkhamis).
|
||||
|
||||
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
|
||||
|
||||
|
||||
+3
-2
@@ -207,8 +207,9 @@ paints before any script runs. `createApplication()` renders the views before
|
||||
anything reads the page; `omitViews` names views the application leaves out,
|
||||
such as `provider-settings`, and their hosts are removed. Leaving out
|
||||
`layer-panels` also leaves out the scene sharing dialogs it draws, and leaving
|
||||
out `scene-chrome` the before/after imagery dividers: the imagery still splits,
|
||||
with no line or handle to move it. An unknown
|
||||
out `scene-chrome` the before/after imagery dividers (the imagery still splits,
|
||||
with no line or handle to move it), the status chips, the toast and the ORBIT
|
||||
indicator. An unknown
|
||||
view name is an error. A host that does not initialize Core's locale passes
|
||||
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||
markup. Node tests that import application modules register the component
|
||||
|
||||
@@ -62,13 +62,17 @@ try {
|
||||
);
|
||||
check(
|
||||
'orbit controls and indicator agree',
|
||||
await page.evaluate(() => {
|
||||
await page.evaluate(async () => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
// Vue renders on a microtask; a task later the indicator shows it.
|
||||
const rendered = () => new Promise((resolve) => setTimeout(resolve));
|
||||
ui.setOrbit(true);
|
||||
await rendered();
|
||||
const active =
|
||||
ui.orbitController.active &&
|
||||
document.getElementById('orbit-indicator').classList.contains('active');
|
||||
ui.setOrbit(false);
|
||||
await rendered();
|
||||
return (
|
||||
active &&
|
||||
!ui.orbitController.active &&
|
||||
|
||||
@@ -831,10 +831,37 @@ test('orbit-toggle controller stops globe motion before toggling', async (t) =>
|
||||
return true;
|
||||
},
|
||||
},
|
||||
_orbitIndicator: { classList: { toggle() {} } },
|
||||
_locationControls: { showOrbit() {} },
|
||||
});
|
||||
assert.equal(toggled, true);
|
||||
});
|
||||
test('the voice orbit switch lights and clears the ORBIT indicator', async () => {
|
||||
const { LocationNavigation } = await import('./ui/locationNavigation.js');
|
||||
const shown = [];
|
||||
let active = false;
|
||||
const nav = Object.assign(Object.create(LocationNavigation.prototype), {
|
||||
services: {},
|
||||
_currentTarget: {},
|
||||
_currentPoi: {},
|
||||
orbitController: {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
toggle() {
|
||||
active = !active;
|
||||
return active;
|
||||
},
|
||||
stop() {
|
||||
active = false;
|
||||
},
|
||||
},
|
||||
_locationControls: { showOrbit: (value) => shown.push(value) },
|
||||
});
|
||||
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
|
||||
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
|
||||
assert.deepEqual(nav.setOrbit(false), { ok: true, orbiting: false });
|
||||
assert.deepEqual(shown, [true, false]);
|
||||
});
|
||||
for (const owner of ['tracking', 'cockpit', 'close-up'])
|
||||
test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => {
|
||||
const { createGevActionRunner } = await import('./voice/gevActions.js');
|
||||
|
||||
@@ -161,6 +161,45 @@ test('the map source row renders the published chips and selects through the con
|
||||
assert.equal(document.querySelectorAll('.map-stack-chip').length, 0);
|
||||
});
|
||||
|
||||
test('the ORBIT indicator sits on the body while offered and lights while orbiting', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#orbit-indicator'), null);
|
||||
const store = useLocationStore(pinia);
|
||||
const controls = new LocationControls({
|
||||
elements: {
|
||||
search: $('#location-search'),
|
||||
searchToggle: $('#search-toggle'),
|
||||
resetButtons: [],
|
||||
},
|
||||
cities: {},
|
||||
getExpandedCity: () => null,
|
||||
onCity() {},
|
||||
onPoi() {},
|
||||
onSearch() {},
|
||||
onReset() {},
|
||||
publish: (view) => store.publish(view),
|
||||
requestFrame() {},
|
||||
cancelFrame() {},
|
||||
});
|
||||
controls.showOrbit(false);
|
||||
await nextTick();
|
||||
const indicator = $('#orbit-indicator');
|
||||
assert.equal(indicator.parentElement, document.body);
|
||||
assert.equal(indicator.textContent, '↻ ORBIT');
|
||||
assert.equal(indicator.querySelector('.orbit-icon').textContent, '↻');
|
||||
assert.equal(indicator.classList.contains('active'), false);
|
||||
controls.showOrbit(true);
|
||||
await nextTick();
|
||||
assert.equal(indicator.classList.contains('active'), true);
|
||||
controls.showOrbit(false);
|
||||
await nextTick();
|
||||
assert.equal(indicator.classList.contains('active'), false);
|
||||
assert.ok($('#orbit-indicator') === indicator, 'stopping keeps it in place');
|
||||
controls.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#orbit-indicator'), null);
|
||||
});
|
||||
|
||||
test('the location tray renders what LocationControls publishes', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#location-mini-city').textContent, '📍 Location: --');
|
||||
|
||||
+24
-19
@@ -16,9 +16,10 @@ const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze(
|
||||
/**
|
||||
* The location tray's presentation, keyboard handling and pending row
|
||||
* animation over supplied actions. The city pills, the landmark row, the
|
||||
* mini status and the search error are presented as a plain view and handed
|
||||
* to `publish`; Core's dock renders them and calls `chooseCity()` and
|
||||
* `choosePoi()`. The search field and reset buttons keep their listeners.
|
||||
* 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.
|
||||
*/
|
||||
export class LocationControls {
|
||||
constructor({
|
||||
@@ -61,6 +62,9 @@ export class LocationControls {
|
||||
poi: t('Landmark: --'),
|
||||
};
|
||||
this.searchError = null;
|
||||
// The ORBIT indicator: null until the shell offers it, then whether the
|
||||
// camera orbits.
|
||||
this.orbit = null;
|
||||
this.bind(doc, 'keydown', (event) => {
|
||||
const cityId = getExpandedCity();
|
||||
if (
|
||||
@@ -100,9 +104,12 @@ export class LocationControls {
|
||||
}
|
||||
/** Publish the tray as it stands. */
|
||||
present() {
|
||||
if (this.destroyed) return;
|
||||
if (!this.destroyed) this.publish(this.presentation());
|
||||
}
|
||||
/** The tray as it stands. */
|
||||
presentation() {
|
||||
const pois = this.cities[this.poiCity]?.pois || [];
|
||||
this.publish({
|
||||
return {
|
||||
cities: Object.entries(this.cities).map(([id, city]) => ({
|
||||
id,
|
||||
label: localizePlaceName(city.name),
|
||||
@@ -118,7 +125,8 @@ export class LocationControls {
|
||||
poisExpanded: this.poisExpanded,
|
||||
status: { ...this.status },
|
||||
searchError: this.searchError,
|
||||
});
|
||||
orbit: this.orbit && { ...this.orbit },
|
||||
};
|
||||
}
|
||||
/** A city pill was clicked. */
|
||||
chooseCity(id) {
|
||||
@@ -197,24 +205,21 @@ export class LocationControls {
|
||||
this.searchError = message || null;
|
||||
this.present();
|
||||
}
|
||||
createOrbitIndicator() {
|
||||
if (this.destroyed) return null;
|
||||
if (!this.orbitIndicator) {
|
||||
this.orbitIndicator = this.doc.createElement('div');
|
||||
this.orbitIndicator.id = 'orbit-indicator';
|
||||
const icon = this.doc.createElement('span');
|
||||
icon.className = 'orbit-icon';
|
||||
icon.textContent = '↻';
|
||||
this.orbitIndicator.append(icon, ` ${t('ORBIT')}`);
|
||||
this.doc.body.appendChild(this.orbitIndicator);
|
||||
}
|
||||
return this.orbitIndicator;
|
||||
/** Offer the ORBIT indicator, lit while the camera orbits. */
|
||||
showOrbit(active = false) {
|
||||
if (this.destroyed) return;
|
||||
this.orbit = { active: Boolean(active) };
|
||||
this.present();
|
||||
}
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.cancelExpansion();
|
||||
for (const remove of this.removers.splice(0)) remove();
|
||||
this.orbitIndicator?.remove();
|
||||
// The ORBIT indicator leaves with its owner.
|
||||
if (this.orbit) {
|
||||
this.orbit = null;
|
||||
this.publish(this.presentation());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,6 @@ function fixture() {
|
||||
};
|
||||
const doc = node();
|
||||
doc.createElement = node;
|
||||
doc.body = node();
|
||||
const cities = {
|
||||
a: { name: 'City A', pois: [{ name: 'First' }, { name: 'Second' }] },
|
||||
b: { name: 'City B', pois: [{ name: 'Elsewhere' }] },
|
||||
@@ -108,6 +107,7 @@ test('the tray presents its cities, empty status and no landmarks at first', ()
|
||||
poisExpanded: false,
|
||||
status: { city: '📍 Location: --', poi: 'Landmark: --' },
|
||||
searchError: null,
|
||||
orbit: null,
|
||||
});
|
||||
f.controls.highlightCity('b');
|
||||
assert.deepEqual(
|
||||
@@ -155,11 +155,13 @@ test('replacing POIs routes clicks to the final row and expands it a frame later
|
||||
test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => {
|
||||
const f = fixture();
|
||||
f.controls.showPois('a');
|
||||
f.controls.createOrbitIndicator();
|
||||
assert.equal(f.doc.body.children.length, 1);
|
||||
f.controls.showOrbit(true);
|
||||
assert.deepEqual(f.view().orbit, { active: true });
|
||||
f.controls.destroy();
|
||||
assert.equal(f.view().orbit, null);
|
||||
f.controls.destroy();
|
||||
const published = f.views.length;
|
||||
f.controls.showOrbit(true);
|
||||
f.controls.chooseCity('a');
|
||||
f.controls.choosePoi(0);
|
||||
f.controls.highlightCity('a');
|
||||
@@ -170,7 +172,6 @@ test('destruction revokes document, search, reset, city and POI actions and remo
|
||||
f.frames.get(0)();
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.equal(f.views.length, published);
|
||||
assert.equal(f.doc.body.children.length, 0);
|
||||
});
|
||||
test('the mini status shows the city, a landmark or a searched place', () => {
|
||||
const f = fixture();
|
||||
|
||||
@@ -32,7 +32,6 @@ export class LocationNavigation {
|
||||
this._cancelGlobeReset = null;
|
||||
this._worldJumpActive = false;
|
||||
this.orbitController = new services.OrbitController(viewer);
|
||||
this._orbitIndicator = null;
|
||||
this._locationState = createStateChannel(
|
||||
() => this._locationLookup?.getState() || null,
|
||||
);
|
||||
@@ -285,7 +284,7 @@ export class LocationNavigation {
|
||||
}
|
||||
|
||||
_initOrbit() {
|
||||
this._orbitIndicator = this._locationControls.createOrbitIndicator();
|
||||
this._locationControls?.showOrbit(false);
|
||||
}
|
||||
|
||||
_toggleOrbit() {
|
||||
@@ -300,13 +299,13 @@ export class LocationNavigation {
|
||||
pitch: this._currentPoi?.pitch || -30,
|
||||
});
|
||||
|
||||
this._orbitIndicator.classList.toggle('active', isActive);
|
||||
this._locationControls?.showOrbit(isActive);
|
||||
}
|
||||
|
||||
_stopOrbit() {
|
||||
if (this.orbitController.active) {
|
||||
this.orbitController.stop();
|
||||
this._orbitIndicator.classList.remove('active');
|
||||
this._locationControls?.showOrbit(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -249,14 +249,6 @@ export class ShellFacade {
|
||||
this._locationNavigation.orbitController = value;
|
||||
}
|
||||
|
||||
get _orbitIndicator() {
|
||||
return this._locationNavigation._orbitIndicator;
|
||||
}
|
||||
|
||||
set _orbitIndicator(value) {
|
||||
this._locationNavigation._orbitIndicator = value;
|
||||
}
|
||||
|
||||
get cockpitView() {
|
||||
return this._cockpitCoordinator?.cockpitView;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* The dock's location tray as its view renders it. LocationControls
|
||||
* presents the city pills, the landmark row, the mini status and the search
|
||||
* error and publishes them here; it keeps the search field's listeners.
|
||||
* presents the city pills, the landmark row, the mini status, the search
|
||||
* error and the ORBIT indicator and publishes them here; it keeps the search
|
||||
* field's listeners.
|
||||
*/
|
||||
export const useLocationStore = defineStore('gev-location', () => {
|
||||
/** The presented tray; null until the controls first render. */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useDisplayStore } from '../stores/display.js';
|
||||
import { useLocationStore } from '../stores/location.js';
|
||||
import { useStatusStore } from '../stores/status.js';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import ImagerySplitView from './panels/ImagerySplitView.vue';
|
||||
@@ -12,6 +13,8 @@ const { style } = storeToRefs(useDisplayStore());
|
||||
// The status chips and toast show what ShellFeedback, CctvControls and
|
||||
// createToast() publish.
|
||||
const { loading, traffic, cctv, toast } = storeToRefs(useStatusStore());
|
||||
// The ORBIT indicator LocationControls offers with the location tray.
|
||||
const { view: location } = storeToRefs(useLocationStore());
|
||||
// Every show rewrites the toast's text, so the live region announces a
|
||||
// repeated message again; hiding leaves it as it fades.
|
||||
const vToastText = {
|
||||
@@ -87,5 +90,9 @@ const vToastText = {
|
||||
<div id="safe-frame-box"></div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="location?.orbit" id="orbit-indicator" v-class-toggle="{ active: location.orbit.active }"><span class="orbit-icon">↻</span> {{ t('ORBIT') }}</div>
|
||||
</Teleport>
|
||||
|
||||
<ImagerySplitView />
|
||||
</template>
|
||||
Reference in new issue
Block a user