mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #390 from bilawalsidhu/refactor/visual-input
refactor(ui): extract application shortcuts and parameter controls
This commit is contained in:
16 files changed
+696
-95
No files matched your search
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Extract application shortcuts and shader-parameter controls into reusable UI
|
||||
components, preserving inputs and cleaning up listeners on rebuild/disposal.
|
||||
|
||||
- Extract adaptive panel rail placement and measurement into reusable UI modules,
|
||||
preserving obstacle clearance, responsive allocation, disclosure and scroll behavior.
|
||||
|
||||
|
||||
@@ -231,3 +231,15 @@ or observers; construction/import does no work. Scheduling, preference writes,
|
||||
share restoration and movement of controls between containers remain caller-owned.
|
||||
Existing helper imports from `cockpitMath.js` and `rightRailPolicy.js` remain
|
||||
compatible through re-exports.
|
||||
|
||||
## Visual input
|
||||
|
||||
`gods-eye-view/ui/input` exports `bindApplicationShortcuts` and
|
||||
`createStyleParameters`. The shortcut binder owns one bubbling keydown listener
|
||||
and receives the document, editing target and explicit action callbacks.
|
||||
Parameter controls own only the supplied container's generated rows/listeners;
|
||||
uniform metadata and read/write/change operations come from the caller.
|
||||
Clearing permits reuse; destruction is final. Neither module imports the app,
|
||||
renderer, persistence or services. The facade retains panel visibility, share
|
||||
restore claims and render scheduling. Both controls are destroyed before the
|
||||
facade's asynchronous teardown can yield.
|
||||
@@ -1,5 +1,18 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Application shortcuts and shader parameter controls
|
||||
|
||||
`ui/input` supplies the bubbling application shortcut listener and generated
|
||||
shader-parameter rows. Number/style keys, H/O/V/F/D/C actions, native form-control
|
||||
typing and Escape behavior retain their existing mappings. Capture-phase
|
||||
surfaces continue to arbitrate their own keyboard events first.
|
||||
|
||||
The UI facade retains shader values, share-restore authority, render requests,
|
||||
search dismissal, visibility and Cockpit portal policy. Parameter rows preserve
|
||||
labels, bounds, steps and precision. Rebuilding rows removes their previous
|
||||
listeners; disposal removes shortcuts and parameter listeners synchronously
|
||||
before asynchronous application teardown.
|
||||
|
||||
## Adaptive panel rail layout
|
||||
|
||||
`ui/layout` supplies the left/right rail layout passes, natural-height
|
||||
|
||||
+2
-1
@@ -121,6 +121,7 @@
|
||||
"./search": "./src/search/index.js",
|
||||
"./ui/panels": "./src/ui/panelDisclosure.js",
|
||||
"./ui/surfaces": "./src/ui/surfaceKeyboard.js",
|
||||
"./ui/layout": "./src/ui/panelRails.js"
|
||||
"./ui/layout": "./src/ui/panelRails.js",
|
||||
"./ui/input": "./src/ui/visualInput.js"
|
||||
}
|
||||
}
|
||||
@@ -129,5 +129,10 @@
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.test.mjs",
|
||||
"src/panelStackLayout.js"
|
||||
"src/panelStackLayout.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/visualInput.test.mjs",
|
||||
"scripts/qa-visual-input.mjs"
|
||||
]
|
||||
@@ -285,5 +285,14 @@
|
||||
"src/panelStackLayout.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"visual-input": {
|
||||
"exports": ["./ui/input"],
|
||||
"modules": [
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/styleParameters.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,27 @@ try {
|
||||
await page.setRequestInterception(true);
|
||||
const routeQaRequest = (request) => {
|
||||
const url = new URL(request.url());
|
||||
// These scenarios exercise Context lifecycle and keyboard ownership, not
|
||||
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
|
||||
// CelesTrak outages cannot invalidate an otherwise clean UI run.
|
||||
if (url.origin === new URL(appUrl).origin
|
||||
&& ['/api/celestrak/active', '/api/celestrak/starlink'].includes(url.pathname)) {
|
||||
const dense = url.pathname.endsWith('/starlink');
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'text/plain',
|
||||
body: (dense ? [
|
||||
'STARLINK-1007',
|
||||
'1 44713U 19074A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
|
||||
'2 44713 53.0000 247.4627 0006703 130.5360 325.0288 15.06000000 12345',
|
||||
] : [
|
||||
'ISS (ZARYA)',
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.49814310 12345',
|
||||
]).join('\n') + '\n',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an
|
||||
// unrelated upstream terrain outage out of the rendered interaction gate.
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') {
|
||||
|
||||
@@ -569,7 +569,13 @@ try {
|
||||
trusted: event.isTrusted, detail: event.detail ?? null,
|
||||
style: event.target.closest('.style-btn')?.dataset.style || null,
|
||||
});
|
||||
for (const type of ['keydown', 'keyup', 'click']) grid.addEventListener(type, record, true);
|
||||
// A claimed hold deliberately blurs the button. Repeats and release then
|
||||
// target the document body, so observe Space beyond the original grid.
|
||||
const recordSpace = (event) => {
|
||||
if (event.code === 'Space') record(event);
|
||||
};
|
||||
for (const type of ['keydown', 'keyup']) document.addEventListener(type, recordSpace, true);
|
||||
grid.addEventListener('click', record, true);
|
||||
manager.setStyle = function (...args) {
|
||||
probe.activations.push(args[0]);
|
||||
return originalSetStyle.apply(this, args);
|
||||
@@ -597,6 +603,11 @@ try {
|
||||
voiceBefore: probe.voiceBefore, voiceNow: voiceState(),
|
||||
selectedStyle: manager.activeStyle, selectedMap: manager.mapStackController.getActiveId(),
|
||||
focusedStyle: document.activeElement?.dataset.style || null,
|
||||
holdObservation: {
|
||||
pageFocused: document.hasFocus(), visibility: document.visibilityState,
|
||||
timerPending: Boolean(voice.pushToTalkHoldTimer),
|
||||
focusOwnerMatches: document.activeElement === voice.pushToTalkHoldFocusOwner,
|
||||
},
|
||||
});
|
||||
probe.reset = () => {
|
||||
probe.events.length = 0;
|
||||
@@ -609,7 +620,8 @@ try {
|
||||
manager.setStyle = originalSetStyle;
|
||||
voice.start = originalVoiceStart;
|
||||
observer.disconnect();
|
||||
for (const type of ['keydown', 'keyup', 'click']) grid.removeEventListener(type, record, true);
|
||||
for (const type of ['keydown', 'keyup']) document.removeEventListener(type, recordSpace, true);
|
||||
grid.removeEventListener('click', record, true);
|
||||
};
|
||||
window.__qaStyleKeyProbe = probe;
|
||||
});
|
||||
@@ -638,9 +650,11 @@ try {
|
||||
await page.keyboard.down('Space');
|
||||
styleSpaceIsDown = true;
|
||||
longSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
// Use the browser's clock, like the production hold timer. Runner-side
|
||||
// delays can finish while Chromium's timer is still pending under load.
|
||||
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 250)));
|
||||
await page.keyboard.down('Space'); // exercise repeat without resetting the hold deadline
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 400)));
|
||||
longSpaceHeld = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
|
||||
await page.keyboard.up('Space');
|
||||
styleSpaceIsDown = false;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/** Browser acceptance for application shortcuts and generated parameter controls. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, passed) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failures += 1;
|
||||
};
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
||||
url.searchParams.set('welcome', '0');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const cover = document.getElementById('loading-screen');
|
||||
return (
|
||||
cover?.classList.contains('hidden') &&
|
||||
Number(getComputedStyle(cover).opacity) === 0
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setStyle('normal');
|
||||
document.activeElement?.blur();
|
||||
});
|
||||
await page.keyboard.press('2');
|
||||
check(
|
||||
'native number shortcut selects CRT',
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
|
||||
),
|
||||
);
|
||||
const initial = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager._sliderPanel.classList.remove('collapsed');
|
||||
const slider = manager._sliderContainer.querySelector('input');
|
||||
window.__qaOldSlider = slider;
|
||||
const name = 'pixelation';
|
||||
window.__qaParameter = {
|
||||
name,
|
||||
before: manager.stages.retro.uniforms[name],
|
||||
};
|
||||
slider.focus();
|
||||
return {
|
||||
before: Number(slider.value),
|
||||
step: Number(slider.step),
|
||||
label: slider.getAttribute('aria-label'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'generated range has an accessible label and a numeric step',
|
||||
initial.label === 'Pixelation' && initial.step > 0,
|
||||
);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
const changed = await page.evaluate(() => {
|
||||
const slider = window.__qaOldSlider;
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
return {
|
||||
value: Number(slider.value),
|
||||
readout: slider.nextElementSibling.textContent,
|
||||
uniform: manager.stages.retro.uniforms[window.__qaParameter.name],
|
||||
};
|
||||
});
|
||||
check(
|
||||
'native range input changes the shader and formatted readout',
|
||||
changed.value > initial.before &&
|
||||
changed.uniform === changed.value &&
|
||||
Number(changed.readout) === changed.value,
|
||||
);
|
||||
await page.keyboard.press('3');
|
||||
check(
|
||||
'focused range retains native input instead of changing style',
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
|
||||
),
|
||||
);
|
||||
check(
|
||||
'replacing a style revokes its detached slider',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const before = manager.stages.retro.uniforms[window.__qaParameter.name];
|
||||
manager.setStyle('thermal');
|
||||
window.__qaOldSlider.value = '0';
|
||||
window.__qaOldSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return (
|
||||
manager.stages.retro.uniforms[window.__qaParameter.name] === before
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'normal style clears rows; reopening restores current shader values',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setStyle('normal');
|
||||
const cleared = manager._sliderContainer.children.length === 0;
|
||||
manager.setStyle('retro');
|
||||
return (
|
||||
cleared &&
|
||||
Number(manager._sliderContainer.querySelector('input').value) ===
|
||||
manager.stages.retro.uniforms[window.__qaParameter.name]
|
||||
);
|
||||
}),
|
||||
);
|
||||
const hudBefore = await page.evaluate(() => {
|
||||
document.getElementById('hud-layout-select').focus();
|
||||
return window.__godsEyeView.styleManager.hud.visible;
|
||||
});
|
||||
await page.keyboard.press('h');
|
||||
check(
|
||||
'HUD select keeps its native typing and selection',
|
||||
await page.evaluate(
|
||||
(visible) =>
|
||||
document.activeElement?.id === 'hud-layout-select' &&
|
||||
window.__godsEyeView.styleManager.hud.visible === visible,
|
||||
hudBefore,
|
||||
),
|
||||
);
|
||||
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
|
||||
'mobile',
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const slider =
|
||||
window.__godsEyeView.styleManager._sliderContainer.querySelector('input');
|
||||
slider.focus();
|
||||
slider.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
});
|
||||
await page.screenshot({ path: 'qa-shots/visual-input/narrow.png' });
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
window.__qaCurrentSlider = manager._sliderContainer.querySelector('input');
|
||||
manager._applicationShortcuts.destroy();
|
||||
manager._styleParameters.destroy();
|
||||
document.activeElement?.blur();
|
||||
});
|
||||
await page.keyboard.press('3');
|
||||
check(
|
||||
'destroyed application shortcuts cannot change the selected style',
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
|
||||
),
|
||||
);
|
||||
check(
|
||||
'destroyed parameter controls cannot write through a retained element',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const before = manager.stages.retro.uniforms[window.__qaParameter.name];
|
||||
window.__qaCurrentSlider.value = '0';
|
||||
window.__qaCurrentSlider.dispatchEvent(
|
||||
new Event('input', { bubbles: true }),
|
||||
);
|
||||
return (
|
||||
manager._sliderContainer.children.length === 0 &&
|
||||
manager.stages.retro.uniforms[window.__qaParameter.name] === before
|
||||
);
|
||||
}),
|
||||
);
|
||||
check('runtime has no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8');
|
||||
|
||||
// Focused markup guards; actual computed names are checked in Chromium.
|
||||
// Native labels and hidden inputs must not be treated as missing aria-labels.
|
||||
@@ -25,6 +25,6 @@ test('the first-run checkbox keeps its native visible label', () => {
|
||||
});
|
||||
|
||||
test('generated style sliders use the visible parameter label as their name', () => {
|
||||
assert.match(ui, /label\.textContent\s*=\s*uMeta\.label;/);
|
||||
assert.match(ui, /slider\.setAttribute\(['"]aria-label['"],\s*uMeta\.label\)/);
|
||||
assert.match(parameters, /label\.textContent\s*=\s*metadata\.label;/);
|
||||
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*metadata\.label\)/);
|
||||
});
|
||||
@@ -417,8 +417,8 @@ test('newer visual, map, and individual panel actions suppress only their owned
|
||||
test('every explicit visual UI gesture claims restore authority before it mutates state', () => {
|
||||
const initUi = sourceBlock(' _initUI() {', ' _initMapStackControl() {');
|
||||
const gestureRoutes = [
|
||||
["if (e.key.toLowerCase() === 'h')", "if (e.key.toLowerCase() === 'o')", 'this.hud.toggle()', 'HUD hotkey'],
|
||||
["if (e.key.toLowerCase() === 'd')", "if (e.key.toLowerCase() === 'c')", 'cycleDetectionMode()', 'detection hotkey'],
|
||||
['toggleHud: () => {', 'toggleOrbit: () =>', 'this.hud.toggle()', 'HUD hotkey'],
|
||||
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
|
||||
['// Bloom toggle', '// Bloom intensity slider', 'this._setBloomEnabled(', 'bloom button'],
|
||||
['// Bloom intensity slider', '// Sharpen toggle', 'this._setBloomIntensity(', 'bloom slider'],
|
||||
['// Sharpen toggle', '// Scope mask', 'this._setSharpenEnabled(', 'sharpen button'],
|
||||
@@ -695,3 +695,13 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
|
||||
newerManager.destroy();
|
||||
assert.equal(cancellations, 1, 'newer navigation must not be cancelled');
|
||||
});
|
||||
|
||||
|
||||
test('visual input listeners are revoked before asynchronous UI teardown', () => {
|
||||
const disposal = uiSource.slice(uiSource.indexOf(' async dispose() {'));
|
||||
const firstAwait = disposal.indexOf('await ');
|
||||
assert.ok(firstAwait > 0);
|
||||
const synchronous = disposal.slice(0, firstAwait);
|
||||
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
|
||||
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
|
||||
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
||||
import * as Cesium from 'cesium';
|
||||
@@ -3399,51 +3400,37 @@ export class StyleManager {
|
||||
btn.addEventListener('click', () => this.setStyle(btn.dataset.style));
|
||||
});
|
||||
|
||||
// Keyboard shortcuts: 1-7, H, Escape
|
||||
this._globalKeydownHandler = (e) => {
|
||||
// Ignore when interacting with a form control (except Escape). Global
|
||||
// hotkeys ('1'-'7', 'h', 'o', 'v', 'd', 'c', 'f') otherwise fire while a
|
||||
// <select> dropdown (e.g. HUD layout) is focused and its native
|
||||
// type-ahead is in use, or while typing in a text field (M9).
|
||||
const isFormControl = e.target?.matches?.('select, input, textarea')
|
||||
|| e.target === this._locationSearch;
|
||||
if (isFormControl && e.key !== 'Escape') return;
|
||||
|
||||
const keyMap = {
|
||||
'1': 'normal', '2': 'retro', '3': 'surveillance',
|
||||
'4': 'thermal', '5': 'anime', '6': 'noir',
|
||||
'7': 'snow',
|
||||
};
|
||||
if (keyMap[e.key]) this.setStyle(keyMap[e.key]);
|
||||
if (e.key === 'Escape') {
|
||||
if (this._locationSearch.classList.contains('expanded')) {
|
||||
this._locationSearch.classList.remove('expanded');
|
||||
this._locationSearch.value = '';
|
||||
this._locationSearch.blur();
|
||||
}
|
||||
}
|
||||
if (e.key.toLowerCase() === 'h') {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.hud.toggle();
|
||||
this._updateHudButtonState();
|
||||
this._syncShareState();
|
||||
}
|
||||
if (e.key.toLowerCase() === 'o') this._toggleOrbit();
|
||||
if (e.key.toLowerCase() === 'v') this.toggleCleanView();
|
||||
if (e.key.toLowerCase() === 'f') {
|
||||
document.getElementById('data-panel').classList.toggle('active');
|
||||
}
|
||||
if (e.key.toLowerCase() === 'd') {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._detectionUserOverridden = true;
|
||||
cycleDetectionMode();
|
||||
this._syncShareState();
|
||||
}
|
||||
if (e.key.toLowerCase() === 'c') {
|
||||
this._toggleCctvEnabled();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this._globalKeydownHandler);
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._applicationShortcuts = bindApplicationShortcuts({
|
||||
documentRef: document,
|
||||
searchInput: this._locationSearch,
|
||||
actions: {
|
||||
setStyle: (style) => this.setStyle(style),
|
||||
dismissSearch: () => {
|
||||
if (this._locationSearch.classList.contains('expanded')) {
|
||||
this._locationSearch.classList.remove('expanded');
|
||||
this._locationSearch.value = '';
|
||||
this._locationSearch.blur();
|
||||
}
|
||||
},
|
||||
toggleHud: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.hud.toggle();
|
||||
this._updateHudButtonState();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleOrbit: () => this._toggleOrbit(),
|
||||
toggleCleanView: () => this.toggleCleanView(),
|
||||
toggleLayers: () => document.getElementById('data-panel').classList.toggle('active'),
|
||||
cycleDetection: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._detectionUserOverridden = true;
|
||||
cycleDetectionMode();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleCctv: () => this._toggleCctvEnabled(),
|
||||
},
|
||||
});
|
||||
|
||||
// Bloom toggle
|
||||
this._bloomBtn.addEventListener('click', () => {
|
||||
@@ -8507,7 +8494,8 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateSliderPanel(styleName, { reveal = false } = {}) {
|
||||
this._sliderContainer.innerHTML = '';
|
||||
this._styleParameters ||= createStyleParameters({ container: this._sliderContainer });
|
||||
this._styleParameters.clear();
|
||||
const shader = STYLES[styleName];
|
||||
|
||||
if (!shader || !shader.uniforms || styleName === 'normal') {
|
||||
@@ -8516,44 +8504,19 @@ export class StyleManager {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [uName, uMeta] of Object.entries(shader.uniforms)) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'param-slider-row';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'param-label';
|
||||
label.textContent = uMeta.label;
|
||||
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = 'param-slider';
|
||||
slider.setAttribute('aria-label', uMeta.label);
|
||||
slider.min = uMeta.min;
|
||||
slider.max = uMeta.max;
|
||||
slider.step = uMeta.max <= 1 ? '0.01' : '0.1';
|
||||
slider.value = this.stages[styleName].uniforms[uName];
|
||||
|
||||
const valueDisplay = document.createElement('span');
|
||||
valueDisplay.className = 'param-value';
|
||||
valueDisplay.textContent = parseFloat(slider.value).toFixed(uMeta.max <= 1 ? 2 : 1);
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
this._styleParameters.render({
|
||||
uniforms: shader.uniforms,
|
||||
readValue: (uName) => this.stages[styleName].uniforms[uName],
|
||||
writeValue: (uName, val) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const val = parseFloat(slider.value);
|
||||
this.stages[styleName].uniforms[uName] = val;
|
||||
valueDisplay.textContent = val.toFixed(uMeta.max <= 1 ? 2 : 1);
|
||||
// Uniform writes don't auto-render under the idle governor —
|
||||
// without this the slider visibly does nothing until the next
|
||||
// camera move (browser finding). (perf wave 2)
|
||||
},
|
||||
onChange: () => {
|
||||
// Uniform writes need an explicit render under the idle governor.
|
||||
governorRequestRender('style-param-slider');
|
||||
this._syncShareState();
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(slider);
|
||||
row.appendChild(valueDisplay);
|
||||
this._sliderContainer.appendChild(row);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this._sliderPanel.classList.add('active');
|
||||
this._scheduleRightPanelLayout();
|
||||
@@ -9726,6 +9689,8 @@ export class StyleManager {
|
||||
this._globalStatusNotice = null;
|
||||
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
|
||||
this._disposed = true;
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._styleParameters?.destroy();
|
||||
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||
this._panelDisclosureControls = [];
|
||||
this._hoverPanelControls?.forEach((control) => control.destroy());
|
||||
@@ -9835,10 +9800,6 @@ export class StyleManager {
|
||||
this._loadingVisibilityHandler = null;
|
||||
}
|
||||
this._stopLoadingFeedbackTicker();
|
||||
if (this._globalKeydownHandler) {
|
||||
document.removeEventListener('keydown', this._globalKeydownHandler);
|
||||
this._globalKeydownHandler = null;
|
||||
}
|
||||
if (this._poiKeydownHandler) {
|
||||
document.removeEventListener('keydown', this._poiKeydownHandler);
|
||||
this._poiKeydownHandler = null;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const STYLE_KEYS = Object.freeze({
|
||||
1: 'normal',
|
||||
2: 'retro',
|
||||
3: 'surveillance',
|
||||
4: 'thermal',
|
||||
5: 'anime',
|
||||
6: 'noir',
|
||||
7: 'snow',
|
||||
});
|
||||
|
||||
/**
|
||||
* Bind the application's existing bubbling keyboard shortcuts.
|
||||
* Capture-phase surfaces keep first refusal. The caller owns each action and
|
||||
* persistence; form controls keep native typing except for Escape.
|
||||
* @param {object} options
|
||||
* @param {Document} options.documentRef Keyboard event target.
|
||||
* @param {HTMLElement} options.searchInput Additional editing target.
|
||||
* @param {object} options.actions Existing application operations.
|
||||
* @returns {{destroy: Function}} Synchronous, idempotent listener cleanup.
|
||||
*/
|
||||
export function bindApplicationShortcuts({
|
||||
documentRef,
|
||||
searchInput,
|
||||
actions,
|
||||
}) {
|
||||
const onKeyDown = (event) => {
|
||||
const isFormControl =
|
||||
event.target?.matches?.('select, input, textarea') ||
|
||||
event.target === searchInput;
|
||||
if (isFormControl && event.key !== 'Escape') return;
|
||||
|
||||
if (STYLE_KEYS[event.key]) actions.setStyle(STYLE_KEYS[event.key]);
|
||||
if (event.key === 'Escape') actions.dismissSearch();
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'h') actions.toggleHud();
|
||||
if (key === 'o') actions.toggleOrbit();
|
||||
if (key === 'v') actions.toggleCleanView();
|
||||
if (key === 'f') actions.toggleLayers();
|
||||
if (key === 'd') actions.cycleDetection();
|
||||
if (key === 'c') actions.toggleCctv();
|
||||
};
|
||||
documentRef.addEventListener('keydown', onKeyDown);
|
||||
return {
|
||||
destroy() {
|
||||
documentRef.removeEventListener('keydown', onKeyDown);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Own generated parameter rows and their input listeners.
|
||||
* The caller supplies uniform metadata/value access and rendering/persistence
|
||||
* callbacks. Replacing or destroying rows revokes all old input handlers.
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.container Container owned by this component.
|
||||
* @param {Document} [options.documentRef] Document used to construct rows.
|
||||
* @returns {{render: Function, clear: Function, destroy: Function}}
|
||||
*/
|
||||
export function createStyleParameters({
|
||||
container,
|
||||
documentRef = container.ownerDocument,
|
||||
}) {
|
||||
let listeners = [];
|
||||
let destroyed = false;
|
||||
const clear = () => {
|
||||
if (destroyed) return;
|
||||
for (const [slider, listener] of listeners)
|
||||
slider.removeEventListener('input', listener);
|
||||
listeners = [];
|
||||
container.replaceChildren();
|
||||
};
|
||||
return {
|
||||
/** Render uniform metadata with readValue, writeValue and onChange callbacks. */
|
||||
render({ uniforms, readValue, writeValue, onChange }) {
|
||||
if (destroyed) return;
|
||||
clear();
|
||||
for (const [name, metadata] of Object.entries(uniforms)) {
|
||||
const row = documentRef.createElement('div');
|
||||
row.className = 'param-slider-row';
|
||||
const label = documentRef.createElement('span');
|
||||
label.className = 'param-label';
|
||||
label.textContent = metadata.label;
|
||||
const slider = documentRef.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = 'param-slider';
|
||||
slider.setAttribute('aria-label', metadata.label);
|
||||
slider.min = metadata.min;
|
||||
slider.max = metadata.max;
|
||||
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
|
||||
slider.value = readValue(name);
|
||||
const digits = metadata.max <= 1 ? 2 : 1;
|
||||
const valueDisplay = documentRef.createElement('span');
|
||||
valueDisplay.className = 'param-value';
|
||||
valueDisplay.textContent = parseFloat(slider.value).toFixed(digits);
|
||||
const onInput = () => {
|
||||
const value = parseFloat(slider.value);
|
||||
writeValue(name, value);
|
||||
valueDisplay.textContent = value.toFixed(digits);
|
||||
onChange();
|
||||
};
|
||||
slider.addEventListener('input', onInput);
|
||||
listeners.push([slider, onInput]);
|
||||
row.append(label, slider, valueDisplay);
|
||||
container.appendChild(row);
|
||||
}
|
||||
},
|
||||
clear,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
clear();
|
||||
destroyed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { bindApplicationShortcuts } from './applicationShortcuts.js';
|
||||
export { createStyleParameters } from './styleParameters.js';
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
bindApplicationShortcuts,
|
||||
createStyleParameters,
|
||||
} from './visualInput.js';
|
||||
|
||||
class Element {
|
||||
constructor(tagName = 'DIV', ownerDocument) {
|
||||
this.tagName = tagName;
|
||||
this.ownerDocument = ownerDocument;
|
||||
this.children = [];
|
||||
this.listeners = new Map();
|
||||
this.attributes = new Map();
|
||||
}
|
||||
addEventListener(type, listener, capture) {
|
||||
assert.notEqual(
|
||||
capture,
|
||||
true,
|
||||
'these controls must not preempt capture-phase surfaces',
|
||||
);
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type).add(listener);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
emit(type, event = {}) {
|
||||
for (const listener of [...(this.listeners.get(type) || [])])
|
||||
listener(event);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
}
|
||||
replaceChildren(...children) {
|
||||
this.children = children;
|
||||
}
|
||||
matches() {
|
||||
return ['INPUT', 'SELECT', 'TEXTAREA'].includes(this.tagName);
|
||||
}
|
||||
}
|
||||
|
||||
function shortcuts() {
|
||||
const documentRef = new Element();
|
||||
const searchInput = new Element();
|
||||
const calls = [];
|
||||
const names = [
|
||||
'setStyle',
|
||||
'dismissSearch',
|
||||
'toggleHud',
|
||||
'toggleOrbit',
|
||||
'toggleCleanView',
|
||||
'toggleLayers',
|
||||
'cycleDetection',
|
||||
'toggleCctv',
|
||||
];
|
||||
const actions = Object.fromEntries(
|
||||
names.map((name) => [name, (...args) => calls.push([name, ...args])]),
|
||||
);
|
||||
const controller = bindApplicationShortcuts({
|
||||
documentRef,
|
||||
searchInput,
|
||||
actions,
|
||||
});
|
||||
const press = (key, target = new Element(), rest = {}) =>
|
||||
documentRef.emit('keydown', { key, target, ...rest });
|
||||
return { documentRef, searchInput, calls, controller, press };
|
||||
}
|
||||
|
||||
test('number keys retain the seven style mappings', () => {
|
||||
const f = shortcuts();
|
||||
for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space'])
|
||||
f.press(key);
|
||||
assert.deepEqual(
|
||||
f.calls,
|
||||
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map(
|
||||
(style) => ['setStyle', style],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('letter shortcuts retain uppercase handling and existing actions', () => {
|
||||
const f = shortcuts();
|
||||
for (const key of ['H', 'o', 'V', 'f', 'D', 'c']) f.press(key);
|
||||
assert.deepEqual(
|
||||
f.calls,
|
||||
[
|
||||
'toggleHud',
|
||||
'toggleOrbit',
|
||||
'toggleCleanView',
|
||||
'toggleLayers',
|
||||
'cycleDetection',
|
||||
'toggleCctv',
|
||||
].map((name) => [name]),
|
||||
);
|
||||
});
|
||||
|
||||
for (const tag of ['INPUT', 'SELECT', 'TEXTAREA']) {
|
||||
test(`${tag} retains native editing, while Escape reaches search dismissal`, () => {
|
||||
const f = shortcuts();
|
||||
for (const key of ['1', 'h', 'o', 'v', 'f', 'd', 'c'])
|
||||
f.press(key, new Element(tag));
|
||||
assert.deepEqual(f.calls, []);
|
||||
f.press('Escape', new Element(tag));
|
||||
assert.deepEqual(f.calls, [['dismissSearch']]);
|
||||
});
|
||||
}
|
||||
|
||||
test('the supplied search target retains editing even without form markup', () => {
|
||||
const f = shortcuts();
|
||||
f.press('1', f.searchInput);
|
||||
f.press('Escape', f.searchInput);
|
||||
assert.deepEqual(f.calls, [['dismissSearch']]);
|
||||
});
|
||||
|
||||
test('shortcut extraction does not change repeat or modifier policy', () => {
|
||||
const f = shortcuts();
|
||||
f.press('h', new Element(), { repeat: true, ctrlKey: true });
|
||||
assert.deepEqual(f.calls, [['toggleHud']]);
|
||||
});
|
||||
|
||||
test('destroy synchronously removes shortcuts and a replacement binds once', () => {
|
||||
const f = shortcuts();
|
||||
f.controller.destroy();
|
||||
f.controller.destroy();
|
||||
f.press('h');
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.equal(f.documentRef.listeners.get('keydown').size, 0);
|
||||
const replacement = bindApplicationShortcuts({
|
||||
documentRef: f.documentRef,
|
||||
searchInput: f.searchInput,
|
||||
actions: { toggleHud: () => f.calls.push('new') },
|
||||
});
|
||||
f.press('h');
|
||||
assert.deepEqual(f.calls, ['new']);
|
||||
replacement.destroy();
|
||||
});
|
||||
|
||||
function parameters() {
|
||||
const documentRef = {
|
||||
createElement: (tag) => new Element(tag.toUpperCase(), documentRef),
|
||||
};
|
||||
const container = new Element('DIV', documentRef);
|
||||
const controller = createStyleParameters({ container });
|
||||
const values = { amount: 0.25, scale: 2 };
|
||||
const uniforms = {
|
||||
amount: { label: '<Amount>', min: 0, max: 1 },
|
||||
scale: { label: 'Scale', min: 0, max: 10 },
|
||||
};
|
||||
const order = [];
|
||||
const options = {
|
||||
uniforms,
|
||||
readValue: (name) => values[name],
|
||||
writeValue: (name, value) => {
|
||||
order.push(['write', name, value]);
|
||||
values[name] = value;
|
||||
},
|
||||
onChange: () => {
|
||||
order.push(['change', container.children[0]?.children[2].textContent]);
|
||||
},
|
||||
};
|
||||
controller.render(options);
|
||||
return { container, controller, values, options, order };
|
||||
}
|
||||
|
||||
test('parameter rows preserve labels, bounds, precision and initial values', () => {
|
||||
const f = parameters();
|
||||
assert.equal(f.container.children.length, 2);
|
||||
const [label, slider, value] = f.container.children[0].children;
|
||||
assert.equal(label.textContent, '<Amount>');
|
||||
assert.equal(slider.attributes.get('aria-label'), '<Amount>');
|
||||
assert.equal(slider.type, 'range');
|
||||
assert.equal(slider.className, 'param-slider');
|
||||
assert.equal(slider.min, 0);
|
||||
assert.equal(slider.max, 1);
|
||||
assert.equal(slider.step, '0.01');
|
||||
assert.equal(value.textContent, '0.25');
|
||||
assert.equal(f.container.children[1].children[1].step, '0.1');
|
||||
assert.equal(f.container.children[1].children[2].textContent, '2.0');
|
||||
assert.deepEqual(
|
||||
f.order,
|
||||
[],
|
||||
'rendering must not write shader values or publish changes',
|
||||
);
|
||||
});
|
||||
|
||||
test('input writes numeric values, updates the readout, then requests a change', () => {
|
||||
const f = parameters();
|
||||
const slider = f.container.children[0].children[1];
|
||||
slider.value = '0.75';
|
||||
slider.emit('input');
|
||||
assert.equal(f.values.amount, 0.75);
|
||||
assert.deepEqual(f.order, [
|
||||
['write', 'amount', 0.75],
|
||||
['change', '0.75'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('rebuilding the parameter panel revokes detached slider listeners', () => {
|
||||
const f = parameters();
|
||||
const oldSlider = f.container.children[0].children[1];
|
||||
f.controller.render({
|
||||
...f.options,
|
||||
uniforms: { scale: f.options.uniforms.scale },
|
||||
});
|
||||
oldSlider.value = '0.8';
|
||||
oldSlider.emit('input');
|
||||
assert.equal(f.values.amount, 0.25);
|
||||
assert.equal(oldSlider.listeners.get('input').size, 0);
|
||||
assert.equal(f.container.children.length, 1);
|
||||
f.container.children[0].children[1].value = '3.5';
|
||||
f.container.children[0].children[1].emit('input');
|
||||
assert.equal(f.values.scale, 3.5);
|
||||
});
|
||||
|
||||
test('clearing removes rows and listeners but allows another style', () => {
|
||||
const f = parameters();
|
||||
const oldSlider = f.container.children[0].children[1];
|
||||
f.controller.clear();
|
||||
oldSlider.emit('input');
|
||||
assert.equal(f.container.children.length, 0);
|
||||
assert.deepEqual(f.order, []);
|
||||
f.controller.render(f.options);
|
||||
assert.equal(f.container.children.length, 2);
|
||||
});
|
||||
|
||||
test('destroy is final and cannot clear a subsequent owner’s DOM', () => {
|
||||
const f = parameters();
|
||||
const oldSlider = f.container.children[0].children[1];
|
||||
f.controller.destroy();
|
||||
const nextOwner = new Element();
|
||||
f.container.appendChild(nextOwner);
|
||||
f.controller.render(f.options);
|
||||
f.controller.clear();
|
||||
f.controller.destroy();
|
||||
oldSlider.emit('input');
|
||||
assert.deepEqual(f.container.children, [nextOwner]);
|
||||
assert.deepEqual(f.order, []);
|
||||
});
|
||||
Reference in new issue
Block a user