feat(ui): toggle rendered frame rate with backtick

This commit is contained in:
Sameh Khamis
2026-09-13 14:51:24 -07:00
parent 53dc685c8d
commit bf6c401e27
10 changed files with 253 additions and 2 deletions
+5
View File
@@ -53,6 +53,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
toggle. Camera departure cancels pending work, arrival checks the final view,
and superseded requests cannot keep a newer view loading.
### Added
- Press backtick (`) to toggle a rendered-frame-rate readout beneath the logo.
Typing fields retain the key; monitoring stops when hidden.
- Extract vessel feed, store, rendering, selection, trail and card components with explicit source and scene services.
- Bound contact retention for incomplete vessel observations, preserve source freshness and refresh history references in place.
+8
View File
@@ -3269,3 +3269,11 @@ while the camera is stationary, backing off from 1.5 to 30 seconds. Failed road
requests report an unavailable source. Leaving the traffic altitude range or
disabling the layer cancels pending work; superseded road and flow requests cannot
release the current request or keep its loading indicator active.
### Optional frame-rate readout
Backtick (`) toggles an FPS readout beneath the title logo. It counts actual
Cesium post-render events over one-second windows and does not request extra
frames. Typing fields, modified keys and key repeats do not toggle it. The
readout starts hidden each session and releases its timer and frame listener
when hidden or when the application is disposed.
+3 -1
View File
@@ -428,5 +428,7 @@
"src/layers/bikeshare/source.test.mjs",
"scripts/traffic-fixtures.mjs",
"src/layers/traffic/navigation.test.mjs",
"scripts/qa-traffic-navigation.mjs"
"scripts/qa-traffic-navigation.mjs",
"src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs"
]
+2 -1
View File
@@ -520,7 +520,8 @@
"src/ui/visualPresets.js",
"src/worldFocus.js",
"src/ui/uiLifetime.js",
"src/app/stateChannel.js"
"src/app/stateChannel.js",
"src/ui/frameRateMonitor.js"
],
"external": ["cesium"]
},
+16
View File
@@ -32,6 +32,16 @@ try {
const result = await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
const counts = {};
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: '`',
code: 'Backquote',
bubbles: true,
cancelable: true,
}),
);
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
const owner =
name === '_draggableResizeObserver'
@@ -210,6 +220,8 @@ try {
ui._lifetime.frames.size === 0 &&
ui._lifetime.timers.size === 0 &&
ui._lifetime.removers.size === 0,
frameRateReleased:
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
stateStopped: stateNotifications === 0,
idempotent: once === JSON.stringify(counts),
};
@@ -218,6 +230,10 @@ try {
window.removeEventListener = removeEventListener;
}
});
check(
'visible frame-rate monitor is removed on UI disposal',
result.frameRateReleased,
);
check(
'real UI has the expected live resources before disposal',
result.connected,
+7
View File
@@ -1,3 +1,4 @@
import { createFrameRateMonitor } from './frameRateMonitor.js';
import { createStateChannel } from '../app/stateChannel.js';
import { setSplitFlapText } from '../splitFlap.js';
import { UiLifetime } from './uiLifetime.js';
@@ -1389,6 +1390,11 @@ export class StyleManager {
setScopeMaskFeather,
} = this.services;
this._applicationShortcuts?.destroy();
this._frameRateMonitor?.destroy();
this._frameRateMonitor = createFrameRateMonitor({
viewer: this.viewer,
documentRef: document,
});
this._applicationShortcuts = bindApplicationShortcuts({
documentRef: document,
searchInput: this._locationSearch,
@@ -5293,6 +5299,7 @@ export class StyleManager {
this._panelLayout.destroy();
this._applicationShortcuts?.destroy();
this._displayControls?.destroy();
this._frameRateMonitor?.destroy();
this._mapSourceControls?.destroy();
this._clearLayersControl?.destroy();
this._locationControls?.destroy();
+81
View File
@@ -0,0 +1,81 @@
/** Count rendered globe frames while the optional readout is visible. */
export function createFrameRateMonitor({ viewer, documentRef = document }) {
const host = documentRef.getElementById('title-bar');
const frameEvent = viewer?.scene?.postRender;
if (!host || !frameEvent) return { destroy() {} };
const readout = documentRef.createElement('div');
readout.className = 'frame-rate-readout';
readout.hidden = true;
readout.textContent = 'FPS —';
readout.title = 'Rendered globe frames per second · toggle with `';
host.appendChild(readout);
let removeFrameListener = null;
let timer = null;
let frames = 0;
let startedAt = 0;
let destroyed = false;
function hide() {
readout.hidden = true;
removeFrameListener?.();
removeFrameListener = null;
clearInterval(timer);
timer = null;
}
function show() {
frames = 0;
startedAt = performance.now();
readout.textContent = 'FPS —';
readout.hidden = false;
removeFrameListener = frameEvent.addEventListener(() => {
frames++;
});
timer = setInterval(() => {
const now = performance.now();
const elapsed = now - startedAt;
readout.textContent =
documentRef.hidden || elapsed <= 0
? 'FPS —'
: `FPS ${Math.round((frames * 1000) / elapsed)}`;
frames = 0;
startedAt = now;
}, 1000);
}
function onKeyDown(event) {
if (event.key !== '`' && event.code !== 'Backquote') return;
if (
event.defaultPrevented ||
event.repeat ||
event.isComposing ||
event.ctrlKey ||
event.altKey ||
event.metaKey ||
event.shiftKey
)
return;
if (
event.target?.isContentEditable ||
event.target?.closest?.(
'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
)
)
return;
event.preventDefault();
if (readout.hidden) show();
else hide();
}
documentRef.addEventListener('keydown', onKeyDown);
return {
destroy() {
if (destroyed) return;
destroyed = true;
hide();
documentRef.removeEventListener('keydown', onKeyDown);
readout.remove();
},
};
}
+109
View File
@@ -0,0 +1,109 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFrameRateMonitor } from './frameRateMonitor.js';
function fixture(t) {
const keys = new Set();
const frames = new Set();
let readout;
const documentRef = {
hidden: false,
getElementById: () => ({
appendChild: (node) => {
readout = node;
},
}),
createElement: () => ({
remove() {
this.removed = true;
},
}),
addEventListener: (name, handler) => keys.add(handler),
removeEventListener: (name, handler) => keys.delete(handler),
};
const viewer = {
scene: {
postRender: {
addEventListener(handler) {
frames.add(handler);
return () => frames.delete(handler);
},
},
},
};
const monitor = createFrameRateMonitor({ viewer, documentRef });
t.after(() => monitor.destroy());
return {
monitor,
documentRef,
frames,
keys,
readout: () => readout,
press(options = {}) {
const event = {
key: '`',
preventDefault() {
this.defaultPrevented = true;
},
...options,
};
for (const handler of keys) handler(event);
return event;
},
};
}
test('FPS measures rendered frames only while shown and releases its resources', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
let now = 0;
t.mock.method(performance, 'now', () => now);
const f = fixture(t);
assert.equal(f.readout().hidden, true);
assert.equal(f.frames.size, 0);
assert.equal(f.press().defaultPrevented, true);
assert.equal(f.frames.size, 1);
for (let frame = 0; frame < 30; frame++)
for (const render of f.frames) render();
now = 1000;
t.mock.timers.tick(1000);
assert.equal(f.readout().textContent, 'FPS 30');
now = 2000;
t.mock.timers.tick(1000);
assert.equal(
f.readout().textContent,
'FPS 0',
'idle rendering is not reported as display refresh rate',
);
f.press();
assert.equal(f.readout().hidden, true);
assert.equal(f.frames.size, 0);
now = 3000;
t.mock.timers.tick(1000);
f.press();
assert.equal(f.readout().textContent, '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);
});
test('typing, modified keys, composition and repeated presses do not toggle FPS', (t) => {
const f = fixture(t);
for (const options of [
{ target: { closest: () => ({}) } },
{ target: { isContentEditable: true } },
{ ctrlKey: true },
{ altKey: true },
{ metaKey: true },
{ shiftKey: true },
{ repeat: true },
{ isComposing: true },
{ defaultPrevented: true },
{ key: 'a' },
]) {
f.press(options);
assert.equal(f.readout().hidden, true);
assert.equal(f.frames.size, 0);
}
});
+9
View File
@@ -591,6 +591,15 @@ body.cockpit-mode #cockpit-cloud-effects.active {
text-transform: uppercase;
}
#title-bar .frame-rate-readout {
margin-top: 6px;
font-family: var(--font-mono);
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
color: var(--accent);
}
/* ── Style Indicator ──────────────────────────── */
#style-indicator {
position: fixed;
+13
View File
@@ -41,6 +41,19 @@
width: 36px;
}
#title-bar .frame-rate-readout {
position: fixed;
top: 52px;
left: 20px;
margin: 0;
font-size: 10px;
line-height: 12px;
}
#title-bar:has(.frame-rate-readout:not([hidden])) .subtitle {
visibility: hidden;
}
#location-bar {
left: 16px;
right: 16px;