mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): render the FPS readout in the title bar from the status store
createFrameRateMonitor() keeps the key, frame count and interval and publishes the readout to the gev-status store; the scene chrome renders it after the subtitle with the same class, hidden state and title. A page without the title bar still gets no monitor, and a monitor only updates or withdraws the readout the store shows.
This commit is contained in:
1 parent
e0abf74308
commit
f9bf782b9c
8 files changed
+158
-45
No files matched your search
@@ -149,7 +149,8 @@ a feature supplying only its accent.
|
|||||||
`gev-status` store; `SplitFlapText` plays the departure-board flip that
|
`gev-status` store; `SplitFlapText` plays the departure-board flip that
|
||||||
`splitFlap.js` plans, writing only the label's one text node; the toast
|
`splitFlap.js` plans, writing only the label's one text node; the toast
|
||||||
renders what each owner's `createToast()` publishes there, on that
|
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
|
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
|
||||||
|
|||||||
@@ -530,6 +530,7 @@
|
|||||||
"scripts/qa-traffic-navigation.mjs",
|
"scripts/qa-traffic-navigation.mjs",
|
||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/frameRateMonitor.test.mjs",
|
"src/ui/frameRateMonitor.test.mjs",
|
||||||
|
"src/ui/frameRateView.test.mjs",
|
||||||
"src/layers/cctv/lifecycle.js",
|
"src/layers/cctv/lifecycle.js",
|
||||||
"src/layers/cctv/controls.js",
|
"src/layers/cctv/controls.js",
|
||||||
"src/layers/cctv/geometry.js",
|
"src/layers/cctv/geometry.js",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ try {
|
|||||||
cancelable: true,
|
cancelable: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// The title bar renders the readout on Vue's next flush.
|
||||||
|
await Promise.resolve();
|
||||||
const fpsReadout = document.querySelector('.frame-rate-readout');
|
const fpsReadout = document.querySelector('.frame-rate-readout');
|
||||||
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
||||||
const watchObserver = (name) => {
|
const watchObserver = (name) => {
|
||||||
@@ -220,7 +222,9 @@ try {
|
|||||||
ui._lifetime.timers.size === 0 &&
|
ui._lifetime.timers.size === 0 &&
|
||||||
ui._lifetime.removers.size === 0,
|
ui._lifetime.removers.size === 0,
|
||||||
frameRateReleased:
|
frameRateReleased:
|
||||||
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
|
fpsWasVisible &&
|
||||||
|
!fpsReadout.isConnected &&
|
||||||
|
!document.querySelector('.frame-rate-readout'),
|
||||||
stateStopped: stateNotifications === 0,
|
stateStopped: stateNotifications === 0,
|
||||||
idempotent: once === JSON.stringify(counts),
|
idempotent: once === JSON.stringify(counts),
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-19
@@ -1,17 +1,29 @@
|
|||||||
import { t } from '../i18n/index.js';
|
import { useStatusStore } from './stores/status.js';
|
||||||
/** Count rendered globe frames while the optional readout is visible. */
|
import { applicationPinia } from './vueRuntime.js';
|
||||||
export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
|
||||||
const host = documentRef.getElementById('title-bar');
|
/**
|
||||||
|
* 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;
|
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
|
// i18n-ignore: FPS is a unit code, the same in every language
|
||||||
readout.textContent = 'FPS —';
|
let readout = { visible: false, text: 'FPS —' };
|
||||||
readout.title = t('Rendered globe frames per second · toggle with `');
|
// Each monitor shows its readout until a newer one takes the title bar.
|
||||||
host.appendChild(readout);
|
store.publishFps(readout);
|
||||||
|
const present = (patch) => {
|
||||||
|
const shown = store.fps === readout;
|
||||||
|
readout = { ...readout, ...patch };
|
||||||
|
if (shown) store.publishFps(readout);
|
||||||
|
};
|
||||||
let removeFrameListener = null;
|
let removeFrameListener = null;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let frames = 0;
|
let frames = 0;
|
||||||
@@ -19,7 +31,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
|||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
readout.hidden = true;
|
present({ visible: false });
|
||||||
removeFrameListener?.();
|
removeFrameListener?.();
|
||||||
removeFrameListener = null;
|
removeFrameListener = null;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -30,19 +42,20 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
|||||||
frames = 0;
|
frames = 0;
|
||||||
startedAt = performance.now();
|
startedAt = performance.now();
|
||||||
// i18n-ignore: FPS is a unit code, the same in every language
|
// i18n-ignore: FPS is a unit code, the same in every language
|
||||||
readout.textContent = 'FPS —';
|
present({ visible: true, text: 'FPS —' });
|
||||||
readout.hidden = false;
|
|
||||||
removeFrameListener = frameEvent.addEventListener(() => {
|
removeFrameListener = frameEvent.addEventListener(() => {
|
||||||
frames++;
|
frames++;
|
||||||
});
|
});
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const elapsed = now - startedAt;
|
const elapsed = now - startedAt;
|
||||||
readout.textContent =
|
present({
|
||||||
|
text:
|
||||||
// i18n-ignore: FPS is a unit code, the same in every language
|
// i18n-ignore: FPS is a unit code, the same in every language
|
||||||
documentRef.hidden || elapsed <= 0
|
documentRef.hidden || elapsed <= 0
|
||||||
? 'FPS —'
|
? 'FPS —'
|
||||||
: `FPS ${Math.round((frames * 1000) / elapsed)}`;
|
: `FPS ${Math.round((frames * 1000) / elapsed)}`,
|
||||||
|
});
|
||||||
frames = 0;
|
frames = 0;
|
||||||
startedAt = now;
|
startedAt = now;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -68,8 +81,8 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (readout.hidden) show();
|
if (readout.visible) hide();
|
||||||
else hide();
|
else show();
|
||||||
}
|
}
|
||||||
|
|
||||||
documentRef.addEventListener('keydown', onKeyDown);
|
documentRef.addEventListener('keydown', onKeyDown);
|
||||||
@@ -79,7 +92,8 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
|||||||
destroyed = true;
|
destroyed = true;
|
||||||
hide();
|
hide();
|
||||||
documentRef.removeEventListener('keydown', onKeyDown);
|
documentRef.removeEventListener('keydown', onKeyDown);
|
||||||
readout.remove();
|
// A newer monitor's readout stays.
|
||||||
|
if (store.fps === readout) store.publishFps(null);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -5,19 +5,15 @@ import { createFrameRateMonitor } from './frameRateMonitor.js';
|
|||||||
function fixture(t) {
|
function fixture(t) {
|
||||||
const keys = new Set();
|
const keys = new Set();
|
||||||
const frames = new Set();
|
const frames = new Set();
|
||||||
let readout;
|
const store = {
|
||||||
|
fps: undefined,
|
||||||
|
publishFps(next) {
|
||||||
|
this.fps = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
const documentRef = {
|
const documentRef = {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
getElementById: () => ({
|
getElementById: (id) => (id === 'title-bar' ? {} : null),
|
||||||
appendChild: (node) => {
|
|
||||||
readout = node;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
createElement: () => ({
|
|
||||||
remove() {
|
|
||||||
this.removed = true;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
addEventListener: (name, handler) => keys.add(handler),
|
addEventListener: (name, handler) => keys.add(handler),
|
||||||
removeEventListener: (name, handler) => keys.delete(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());
|
t.after(() => monitor.destroy());
|
||||||
return {
|
return {
|
||||||
monitor,
|
monitor,
|
||||||
documentRef,
|
documentRef,
|
||||||
frames,
|
frames,
|
||||||
keys,
|
keys,
|
||||||
readout: () => readout,
|
readout: () => store.fps,
|
||||||
press(options = {}) {
|
press(options = {}) {
|
||||||
const event = {
|
const event = {
|
||||||
key: '`',
|
key: '`',
|
||||||
@@ -58,7 +54,7 @@ test('FPS measures rendered frames only while shown and releases its resources',
|
|||||||
let now = 0;
|
let now = 0;
|
||||||
t.mock.method(performance, 'now', () => now);
|
t.mock.method(performance, 'now', () => now);
|
||||||
const f = fixture(t);
|
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.frames.size, 0);
|
||||||
assert.equal(f.press().defaultPrevented, true);
|
assert.equal(f.press().defaultPrevented, true);
|
||||||
assert.equal(f.frames.size, 1);
|
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();
|
for (const render of f.frames) render();
|
||||||
now = 1000;
|
now = 1000;
|
||||||
t.mock.timers.tick(1000);
|
t.mock.timers.tick(1000);
|
||||||
assert.equal(f.readout().textContent, 'FPS 30');
|
assert.equal(f.readout().text, 'FPS 30');
|
||||||
now = 2000;
|
now = 2000;
|
||||||
t.mock.timers.tick(1000);
|
t.mock.timers.tick(1000);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.readout().textContent,
|
f.readout().text,
|
||||||
'FPS 0',
|
'FPS 0',
|
||||||
'idle rendering is not reported as display refresh rate',
|
'idle rendering is not reported as display refresh rate',
|
||||||
);
|
);
|
||||||
f.press();
|
f.press();
|
||||||
assert.equal(f.readout().hidden, true);
|
assert.equal(f.readout().visible, false);
|
||||||
assert.equal(f.frames.size, 0);
|
assert.equal(f.frames.size, 0);
|
||||||
now = 3000;
|
now = 3000;
|
||||||
t.mock.timers.tick(1000);
|
t.mock.timers.tick(1000);
|
||||||
f.press();
|
f.press();
|
||||||
assert.equal(f.readout().textContent, 'FPS —');
|
assert.equal(f.readout().text, 'FPS —');
|
||||||
f.monitor.destroy();
|
f.monitor.destroy();
|
||||||
f.monitor.destroy();
|
f.monitor.destroy();
|
||||||
assert.equal(f.frames.size, 0);
|
assert.equal(f.frames.size, 0);
|
||||||
assert.equal(f.keys.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) => {
|
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' },
|
{ key: 'a' },
|
||||||
]) {
|
]) {
|
||||||
f.press(options);
|
f.press(options);
|
||||||
assert.equal(f.readout().hidden, true);
|
assert.equal(f.readout().visible, false);
|
||||||
assert.equal(f.frames.size, 0);
|
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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
+15
-1
@@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue';
|
|||||||
* The scene's status chips as their views render them. ShellFeedback
|
* The scene's status chips as their views render them. ShellFeedback
|
||||||
* (src/ui/shellFeedback.js) publishes the top-center loading status and the
|
* (src/ui/shellFeedback.js) publishes the top-center loading status and the
|
||||||
* road network chip, CctvControls the camera grid chip; the toast shows
|
* 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.
|
* last text while hidden, so the next change flaps from what was shown.
|
||||||
*/
|
*/
|
||||||
export const useStatusStore = defineStore('gev-status', () => {
|
export const useStatusStore = defineStore('gev-status', () => {
|
||||||
@@ -25,6 +26,12 @@ export const useStatusStore = defineStore('gev-status', () => {
|
|||||||
*/
|
*/
|
||||||
const toast = shallowRef(null);
|
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. */
|
/** @param {?object} next A fresh presentation of the loading status. */
|
||||||
function publishLoading(next) {
|
function publishLoading(next) {
|
||||||
loading.value = next && markRaw(next);
|
loading.value = next && markRaw(next);
|
||||||
@@ -45,7 +52,14 @@ export const useStatusStore = defineStore('gev-status', () => {
|
|||||||
toast.value = next && markRaw(next);
|
toast.value = next && markRaw(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {?{visible: boolean, text: string}} next */
|
||||||
|
function publishFps(next) {
|
||||||
|
fps.value = next && markRaw(next);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
fps,
|
||||||
|
publishFps,
|
||||||
toast,
|
toast,
|
||||||
publishToast,
|
publishToast,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import SplitFlapText from './panels/SplitFlapText.vue';
|
|||||||
|
|
||||||
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
||||||
const { style } = storeToRefs(useDisplayStore());
|
const { style } = storeToRefs(useDisplayStore());
|
||||||
// The status chips and toast show what ShellFeedback, CctvControls and
|
// The status chips, toast and FPS readout show what ShellFeedback,
|
||||||
// createToast() publish.
|
// CctvControls, createToast() and createFrameRateMonitor() publish.
|
||||||
const { loading, traffic, cctv, toast } = storeToRefs(useStatusStore());
|
const { loading, traffic, cctv, toast, fps } = storeToRefs(useStatusStore());
|
||||||
// The ORBIT indicator LocationControls offers with the location tray.
|
// The ORBIT indicator LocationControls offers with the location tray.
|
||||||
const { view: location } = storeToRefs(useLocationStore());
|
const { view: location } = storeToRefs(useLocationStore());
|
||||||
// Every show rewrites the toast's text, so the live region announces a
|
// Every show rewrites the toast's text, so the live region announces a
|
||||||
@@ -44,6 +44,7 @@ const vToastText = {
|
|||||||
<div class="title-glow"></div>
|
<div class="title-glow"></div>
|
||||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||||
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
|
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||||
|
<div v-if="fps" class="frame-rate-readout scene-text-holdout" :hidden="!fps.visible" :title="t('Rendered globe frames per second · toggle with `')">{{ fps.text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Style indicator -->
|
<!-- Style indicator -->
|
||||||
|
|||||||
Reference in new issue
Block a user