diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 77572f4c1..60b6c4475 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -149,7 +149,8 @@ a feature supplying only its accent. `gev-status` store; `SplitFlapText` plays the departure-board flip that `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), the Scene actions panel (`SceneInteractions` + owner's own timer, and the title bar's FPS readout what + `createFrameRateMonitor()` publishes), the Scene actions panel (`SceneInteractions` teleports what the scene interactions owner publishes to the `gev-scene-interactions` store into the viewer's container; the owner keeps the pick handler and focuses the picked action), the data-pack diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 661a9e563..d1498f08b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -530,6 +530,7 @@ "scripts/qa-traffic-navigation.mjs", "src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.test.mjs", + "src/ui/frameRateView.test.mjs", "src/layers/cctv/lifecycle.js", "src/layers/cctv/controls.js", "src/layers/cctv/geometry.js", diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index f3d82e01e..9c351f388 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -40,6 +40,8 @@ try { cancelable: true, }), ); + // The title bar renders the readout on Vue's next flush. + await Promise.resolve(); const fpsReadout = document.querySelector('.frame-rate-readout'); const fpsWasVisible = fpsReadout && !fpsReadout.hidden; const watchObserver = (name) => { @@ -220,7 +222,9 @@ try { ui._lifetime.timers.size === 0 && ui._lifetime.removers.size === 0, frameRateReleased: - fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden, + fpsWasVisible && + !fpsReadout.isConnected && + !document.querySelector('.frame-rate-readout'), stateStopped: stateNotifications === 0, idempotent: once === JSON.stringify(counts), }; diff --git a/src/ui/frameRateMonitor.js b/src/ui/frameRateMonitor.js index 2f6ab8e8d..233041b08 100644 --- a/src/ui/frameRateMonitor.js +++ b/src/ui/frameRateMonitor.js @@ -1,17 +1,29 @@ -import { t } from '../i18n/index.js'; -/** Count rendered globe frames while the optional readout is visible. */ -export function createFrameRateMonitor({ viewer, documentRef = document }) { - const host = documentRef.getElementById('title-bar'); +import { useStatusStore } from './stores/status.js'; +import { applicationPinia } from './vueRuntime.js'; + +/** + * Count rendered globe frames while the optional readout is visible. The + * readout is presented to the status store, which the title bar renders. + */ +export function createFrameRateMonitor({ + viewer, + documentRef = document, + store = useStatusStore(applicationPinia()), +}) { const frameEvent = viewer?.scene?.postRender; - if (!host || !frameEvent) return { destroy() {} }; + // A page without the scene chrome has no title bar to show it in. + if (!documentRef.getElementById('title-bar') || !frameEvent) + return { destroy() {} }; - const readout = documentRef.createElement('div'); - readout.className = 'frame-rate-readout scene-text-holdout'; - readout.hidden = true; // i18n-ignore: FPS is a unit code, the same in every language - readout.textContent = 'FPS —'; - readout.title = t('Rendered globe frames per second · toggle with `'); - host.appendChild(readout); + let readout = { visible: false, text: 'FPS —' }; + // Each monitor shows its readout until a newer one takes the title bar. + store.publishFps(readout); + const present = (patch) => { + const shown = store.fps === readout; + readout = { ...readout, ...patch }; + if (shown) store.publishFps(readout); + }; let removeFrameListener = null; let timer = null; let frames = 0; @@ -19,7 +31,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) { let destroyed = false; function hide() { - readout.hidden = true; + present({ visible: false }); removeFrameListener?.(); removeFrameListener = null; clearInterval(timer); @@ -30,19 +42,20 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) { frames = 0; startedAt = performance.now(); // i18n-ignore: FPS is a unit code, the same in every language - readout.textContent = 'FPS —'; - readout.hidden = false; + present({ visible: true, text: 'FPS —' }); removeFrameListener = frameEvent.addEventListener(() => { frames++; }); timer = setInterval(() => { const now = performance.now(); const elapsed = now - startedAt; - readout.textContent = - // i18n-ignore: FPS is a unit code, the same in every language - documentRef.hidden || elapsed <= 0 - ? 'FPS —' - : `FPS ${Math.round((frames * 1000) / elapsed)}`; + present({ + text: + // i18n-ignore: FPS is a unit code, the same in every language + documentRef.hidden || elapsed <= 0 + ? 'FPS —' + : `FPS ${Math.round((frames * 1000) / elapsed)}`, + }); frames = 0; startedAt = now; }, 1000); @@ -68,8 +81,8 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) { ) return; event.preventDefault(); - if (readout.hidden) show(); - else hide(); + if (readout.visible) hide(); + else show(); } documentRef.addEventListener('keydown', onKeyDown); @@ -79,7 +92,8 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) { destroyed = true; hide(); documentRef.removeEventListener('keydown', onKeyDown); - readout.remove(); + // A newer monitor's readout stays. + if (store.fps === readout) store.publishFps(null); }, }; } diff --git a/src/ui/frameRateMonitor.test.mjs b/src/ui/frameRateMonitor.test.mjs index 47df99fdb..fcf482ce6 100644 --- a/src/ui/frameRateMonitor.test.mjs +++ b/src/ui/frameRateMonitor.test.mjs @@ -5,19 +5,15 @@ import { createFrameRateMonitor } from './frameRateMonitor.js'; function fixture(t) { const keys = new Set(); const frames = new Set(); - let readout; + const store = { + fps: undefined, + publishFps(next) { + this.fps = next; + }, + }; const documentRef = { hidden: false, - getElementById: () => ({ - appendChild: (node) => { - readout = node; - }, - }), - createElement: () => ({ - remove() { - this.removed = true; - }, - }), + getElementById: (id) => (id === 'title-bar' ? {} : null), addEventListener: (name, handler) => keys.add(handler), removeEventListener: (name, handler) => keys.delete(handler), }; @@ -31,14 +27,14 @@ function fixture(t) { }, }, }; - const monitor = createFrameRateMonitor({ viewer, documentRef }); + const monitor = createFrameRateMonitor({ viewer, documentRef, store }); t.after(() => monitor.destroy()); return { monitor, documentRef, frames, keys, - readout: () => readout, + readout: () => store.fps, press(options = {}) { const event = { key: '`', @@ -58,7 +54,7 @@ test('FPS measures rendered frames only while shown and releases its resources', let now = 0; t.mock.method(performance, 'now', () => now); const f = fixture(t); - assert.equal(f.readout().hidden, true); + assert.equal(f.readout().visible, false); assert.equal(f.frames.size, 0); assert.equal(f.press().defaultPrevented, true); assert.equal(f.frames.size, 1); @@ -66,26 +62,26 @@ test('FPS measures rendered frames only while shown and releases its resources', for (const render of f.frames) render(); now = 1000; t.mock.timers.tick(1000); - assert.equal(f.readout().textContent, 'FPS 30'); + assert.equal(f.readout().text, 'FPS 30'); now = 2000; t.mock.timers.tick(1000); assert.equal( - f.readout().textContent, + f.readout().text, 'FPS 0', 'idle rendering is not reported as display refresh rate', ); f.press(); - assert.equal(f.readout().hidden, true); + assert.equal(f.readout().visible, false); assert.equal(f.frames.size, 0); now = 3000; t.mock.timers.tick(1000); f.press(); - assert.equal(f.readout().textContent, 'FPS —'); + assert.equal(f.readout().text, 'FPS —'); f.monitor.destroy(); f.monitor.destroy(); assert.equal(f.frames.size, 0); assert.equal(f.keys.size, 0); - assert.equal(f.readout().removed, true); + assert.equal(f.readout(), null, 'destroy withdraws the readout'); }); test('typing, modified keys, composition and repeated presses do not toggle FPS', (t) => { @@ -103,7 +99,45 @@ test('typing, modified keys, composition and repeated presses do not toggle FPS' { key: 'a' }, ]) { f.press(options); - assert.equal(f.readout().hidden, true); + assert.equal(f.readout().visible, false); assert.equal(f.frames.size, 0); } }); + +test('a page without the title bar gets no readout and keeps the key', () => { + let listened = false; + const store = { publishFps: () => assert.fail('nothing to show it in') }; + const monitor = createFrameRateMonitor({ + viewer: { scene: { postRender: { addEventListener: () => () => {} } } }, + documentRef: { + getElementById: () => null, + addEventListener: () => (listened = true), + }, + store, + }); + monitor.destroy(); + assert.equal(listened, false); +}); + +test("destroying an earlier monitor leaves a newer one's readout", () => { + const store = { + publishFps(next) { + this.fps = next; + }, + }; + const documentRef = { + getElementById: () => ({}), + addEventListener() {}, + removeEventListener() {}, + }; + const viewer = { + scene: { postRender: { addEventListener: () => () => {} } }, + }; + const first = createFrameRateMonitor({ viewer, documentRef, store }); + const second = createFrameRateMonitor({ viewer, documentRef, store }); + const shown = store.fps; + first.destroy(); + assert.ok(store.fps === shown); + second.destroy(); + assert.equal(store.fps, null); +}); diff --git a/src/ui/frameRateView.test.mjs b/src/ui/frameRateView.test.mjs new file mode 100644 index 000000000..7b25f7cb9 --- /dev/null +++ b/src/ui/frameRateView.test.mjs @@ -0,0 +1,44 @@ +// src/ui/frameRateView.test.mjs — the title bar's FPS readout renders what +// createFrameRateMonitor() publishes and toggles with the backquote key. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useStatusStore } from './stores/status.js'; +import { createFrameRateMonitor } from './frameRateMonitor.js'; +import SceneChromeView from './views/SceneChromeView.vue'; + +test('the FPS readout sits in the title bar, hidden until the backquote key', async (t) => { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(SceneChromeView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + const readout = () => + document.querySelector('#title-bar .frame-rate-readout'); + assert.equal(readout(), null, 'no readout without a monitor'); + const monitor = createFrameRateMonitor({ + viewer: { scene: { postRender: { addEventListener: () => () => {} } } }, + store: useStatusStore(pinia), + }); + t.after(() => monitor.destroy()); + await nextTick(); + const element = readout(); + assert.ok(element.previousElementSibling.classList.contains('subtitle')); + assert.ok(element.classList.contains('scene-text-holdout')); + assert.equal(element.hidden, true); + assert.equal(element.textContent, 'FPS —'); + assert.equal( + element.title, + 'Rendered globe frames per second · toggle with `', + ); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: '`', bubbles: true, cancelable: true }), + ); + await nextTick(); + assert.equal(readout().hidden, false); + monitor.destroy(); + await nextTick(); + assert.equal(readout(), null); +}); diff --git a/src/ui/stores/status.js b/src/ui/stores/status.js index 599b320b7..34f66233d 100644 --- a/src/ui/stores/status.js +++ b/src/ui/stores/status.js @@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue'; * The scene's status chips as their views render them. ShellFeedback * (src/ui/shellFeedback.js) publishes the top-center loading status and the * road network chip, CctvControls the camera grid chip; the toast shows - * what any owner's createToast() (src/ui/toast.js) publishes. Each label keeps its + * what any owner's createToast() (src/ui/toast.js) publishes, and the title + * bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its * last text while hidden, so the next change flaps from what was shown. */ export const useStatusStore = defineStore('gev-status', () => { @@ -25,6 +26,12 @@ export const useStatusStore = defineStore('gev-status', () => { */ const toast = shallowRef(null); + /** + * The title bar's FPS readout, `{visible, text}`; null while no monitor + * counts frames. + */ + const fps = shallowRef(null); + /** @param {?object} next A fresh presentation of the loading status. */ function publishLoading(next) { loading.value = next && markRaw(next); @@ -45,7 +52,14 @@ export const useStatusStore = defineStore('gev-status', () => { toast.value = next && markRaw(next); } + /** @param {?{visible: boolean, text: string}} next */ + function publishFps(next) { + fps.value = next && markRaw(next); + } + return { + fps, + publishFps, toast, publishToast, loading, diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 430226bae..190b4a273 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -12,9 +12,9 @@ import SplitFlapText from './panels/SplitFlapText.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); -// The status chips and toast show what ShellFeedback, CctvControls and -// createToast() publish. -const { loading, traffic, cctv, toast } = storeToRefs(useStatusStore()); +// The status chips, toast and FPS readout show what ShellFeedback, +// CctvControls, createToast() and createFrameRateMonitor() publish. +const { loading, traffic, cctv, toast, fps } = 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 @@ -44,6 +44,7 @@ const vToastText = {

GOD'S EYE VIEW

NO PLACE LEFT BEHIND

+
{{ fps.text }}