diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b555f592..9e8b3915d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 809b106f4..91dbc99ed 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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 diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 060521d93..92e10c5f9 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -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 && diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index db9cbd852..814e4de1e 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -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'); diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index 46b7c855b..4e8983ac0 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -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: --'); diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 4b53e8f60..4a94dc4e2 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -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()); + } } } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index baddaa896..42466ee19 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -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(); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 07aa9db32..a9c9d5a71 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -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); } } diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 1d8f612c8..aeaebb0fd 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -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; } diff --git a/src/ui/stores/location.js b/src/ui/stores/location.js index ac1344ca7..3ef58aa9a 100644 --- a/src/ui/stores/location.js +++ b/src/ui/stores/location.js @@ -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. */ diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 552880233..b165d1adf 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -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 = {
+ +
↻ {{ t('ORBIT') }}
+
+