From bf6c401e27f6b90df711e9162c1cc274de10aec6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 14:50:14 -0700 Subject: [PATCH] feat(ui): toggle rendered frame rate with backtick --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 8 +++ scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 3 +- scripts/qa-ui-disposal.mjs | 16 +++++ src/ui/applicationShell.js | 7 ++ src/ui/frameRateMonitor.js | 81 +++++++++++++++++++++++ src/ui/frameRateMonitor.test.mjs | 109 +++++++++++++++++++++++++++++++ src/ui/styles/foundation.css | 9 +++ src/ui/styles/responsive.css | 13 ++++ 10 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/ui/frameRateMonitor.js create mode 100644 src/ui/frameRateMonitor.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ee6b519..69b7c4407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfa7ad5aa..0ffd17bc4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 155914a9a..708a319bc 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3a9c70e14..e48997e2a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 5414de6d3..d393648df 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -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, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 9fc915525..f347a91c2 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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(); diff --git a/src/ui/frameRateMonitor.js b/src/ui/frameRateMonitor.js new file mode 100644 index 000000000..dd63311ca --- /dev/null +++ b/src/ui/frameRateMonitor.js @@ -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(); + }, + }; +} diff --git a/src/ui/frameRateMonitor.test.mjs b/src/ui/frameRateMonitor.test.mjs new file mode 100644 index 000000000..47df99fdb --- /dev/null +++ b/src/ui/frameRateMonitor.test.mjs @@ -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); + } +}); diff --git a/src/ui/styles/foundation.css b/src/ui/styles/foundation.css index cbb5a5eac..73582b962 100644 --- a/src/ui/styles/foundation.css +++ b/src/ui/styles/foundation.css @@ -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; diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index f07e5c1ff..6e381896e 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -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;