From c30f7cc3be779584d1ec51214bea4785e3ffa82b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 21:55:18 -0700 Subject: [PATCH 01/92] refactor(ui): separate visual effects and animation ownership --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 10 + docs/CURRENT-STATE.md | 8 + package.json | 4 +- scripts/package-boundaries.json | 5 + scripts/qa-visual-effects.mjs | 106 +++++++++ src/contactsDetectionPolicy.test.mjs | 6 +- src/data/detectionHost.test.mjs | 5 +- src/reasonableDefaults.test.mjs | 6 +- src/sharelink.celestial.test.mjs | 1 + src/ui.js | 335 +++------------------------ src/ui/effects.js | 2 + src/ui/visualEffects.js | 182 +++++++++++++++ src/ui/visualEffects.test.mjs | 185 +++++++++++++++ src/ui/visualPresets.js | 135 +++++++++++ 15 files changed, 684 insertions(+), 308 deletions(-) create mode 100644 scripts/qa-visual-effects.mjs create mode 100644 src/ui/effects.js create mode 100644 src/ui/visualEffects.js create mode 100644 src/ui/visualEffects.test.mjs create mode 100644 src/ui/visualPresets.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f654587..5855ec39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown. + - Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior. - Extract application shortcuts and shader-parameter controls into reusable UI diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 607315d4b..db1e450bc 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -250,3 +250,13 @@ facade's asynchronous teardown can yield. DOM elements and explicit actions, imports no application or effect singleton, and releases every listener on destruction. Settings and rendering remain with the caller. + +## Visual effects + +`ui/effects` exports the effects controller and existing preset definitions. +It owns shader stages and their clock, with explicit render ownership callbacks. +Construction installs no stages or frame callbacks. Stop animation before +releasing UI consumers, then destroy to remove owned stages and restore the +borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers +without loading the renderer. UI presentation and product-action coordination +remain in their callers. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61d996883..2461def9e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +## Visual effects ownership + +VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom +and sharpen. Display supplies actions and renders settings; effect execution has +no DOM dependency. Existing presets and the 500 ms transition remain unchanged. +Stopping the controller revokes animation before asynchronous UI teardown; final +destruction removes its stages and restores the borrowed bloom configuration. + ## Display control ownership Display button, selector and slider listeners have a single destroyable owner. diff --git a/package.json b/package.json index aae850dc0..d6ecae4fb 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,8 @@ "./ui/surfaces": "./src/ui/surfaceKeyboard.js", "./ui/layout": "./src/ui/panelRails.js", "./ui/input": "./src/ui/visualInput.js", - "./ui/display": "./src/ui/displayControls.js" + "./ui/display": "./src/ui/displayControls.js", + "./ui/effects": "./src/ui/effects.js", + "./ui/effects/bloom": "./src/bloom.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 91296df96..946066474 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -299,5 +299,10 @@ "exports": ["./ui/display"], "modules": ["src/ui/displayControls.js"], "external": [] + }, + "visual-effects": { + "exports": ["./ui/effects", "./ui/effects/bloom"], + "modules": ["src/ui/effects.js", "src/ui/visualEffects.js", "src/ui/visualPresets.js", "src/bloom.js", "src/styles/retro.js", "src/styles/surveillance.js", "src/styles/thermal.js", "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js"], + "external": ["cesium"] } } diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs new file mode 100644 index 000000000..b2a5fe656 --- /dev/null +++ b/scripts/qa-visual-effects.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** Rendered acceptance for style transitions and effect-state ownership. */ +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; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +} +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?._visualEffects && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60_000 }); + await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.scene.tweens?.removeAll?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ longitude: -97.7431 * Math.PI / 180, latitude: 30.2568 * Math.PI / 180, height: 1200 }), + orientation: { heading: 0, pitch: -0.85, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const settled = await page.waitForFunction(() => { + const primitives = window.__godsEyeView.viewer.scene.primitives; + for (let i = 0; i < primitives.length; i++) { + const primitive = primitives.get(i); + if (typeof primitive.tilesLoaded === 'boolean' && !primitive.tilesLoaded) return false; + } + return true; + }, { timeout: 60_000 }).then(() => true, () => false); + check('visible tile content settles before visual captures', settled); + fs.mkdirSync('qa-shots/visual-effects', { recursive: true }); + for (const style of ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'normal']) { + await page.evaluate((name) => window.__godsEyeView.styleManager.setStyle(name), style); + await page.waitForFunction(() => window.__godsEyeView.styleManager.transitions.size === 0, { timeout: 10_000 }); + const state = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const effects = manager._visualEffects; + const visible = Object.entries(effects.stages).filter(([, stage]) => stage.enabled).map(([name]) => name); + const animated = Object.values(effects.stages).some((stage) => stage.enabled && stage.uniforms.time !== undefined && stage.uniforms.intensity > 0.001); + return { active: manager.activeStyle, visible, clock: effects.frameId !== null, animated }; + }); + check(`${style}: only the selected style remains visible`, state.active === style && JSON.stringify(state.visible) === JSON.stringify(style === 'normal' ? [] : [style])); + check(`${style}: the animation clock follows visible animated stages`, state.clock === state.animated); + if (['normal', 'surveillance', 'thermal'].includes(style)) { + await page.screenshot({ path: `qa-shots/visual-effects/${style}.png` }); + if (style === 'surveillance') await page.evaluate(() => window.__godsEyeView.viewer.camera.lookRight(0.2)); + } + } + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setStyle('noir'); manager.setStyle('retro'); manager.setStyle('normal'); + }); + await page.waitForFunction(() => window.__godsEyeView.styleManager.transitions.size === 0, { timeout: 10_000 }); + check('rapid switches settle to Normal without an old style or clock', await page.evaluate(() => { + const effects = window.__godsEyeView.styleManager._visualEffects; + return effects.frameId === null && Object.values(effects.stages).every((stage) => !stage.enabled); + })); + check('bloom actions, effect state and saved snapshot agree', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const result = manager.setBloom({ enabled: true, intensityPct: 98 }); + const snapshot = manager.getVisualState(); + return result.ok && result.bloom.intensityPct === 98 && snapshot.bloom.intensity === 98 && manager._visualEffects.bloomIntensity === 98 && manager._bloomStage.enabled; + })); + check('sharpen actions, effect state and saved snapshot agree', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const result = manager.setSharpen({ enabled: true, intensityPct: 61 }); + return result.ok && result.sharpen.intensityPct === 61 && manager.getVisualState().sharpen.intensity === 61 && manager._visualEffects.sharpenIntensity === 0.61 && Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9; + })); + await page.setViewport({ width: 620, height: 900 }); + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { persist: false, syncShare: false })); + await page.screenshot({ path: 'qa-shots/visual-effects/narrow.png' }); + const teardown = await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const effects = manager._visualEffects; + manager.setStyle('retro'); + const owned = [...Object.values(effects.stages), effects.sharpenStage]; + const collection = manager.viewer.scene.postProcessStages; + const previousBloom = effects.previousBloom; + const pending = manager.dispose(); + const stoppedBeforeAwait = effects.stopped && effects.frameId === null && effects.transitions.size === 0; + await pending; + return { + stoppedBeforeAwait, + removed: owned.every((stage) => !collection.contains(stage)), + restored: effects.bloomStage.enabled === previousBloom.enabled && Object.entries(previousBloom.uniforms).every(([name, value]) => effects.bloomStage.uniforms[name] === value), + }; + }); + check('UI teardown stops effect animation before its first await', teardown.stoppedBeforeAwait); + check('UI teardown removes owned stages and restores borrowed bloom', teardown.removed && teardown.restored); + check('effects do not introduce uncaught browser errors', errors.length === 0); + if (errors.length) console.log(JSON.stringify(errors)); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 0dcb5a6f0..98b2401ef 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -26,7 +26,9 @@ import { } from './data/detection.js'; import { canonicalizeDensity } from './data/detectionPolicy.js'; -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +// Follow the UI wiring and its extracted preset definitions. +const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8') + + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); /** * The tactical preset ui.js hands Contacts. Read out of the source so this test @@ -34,7 +36,7 @@ const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); */ const MILITARY_PRESET = (() => { const match = uiSource.match( - /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: '(\w+)', densityPct: (\d+) \}\)/, + /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: '(\w+)',\s*densityPct: (\d+),?\s*\}\)/, ); assert.ok(match, 'ui.js must expose one shared military detection preset'); return { mode: match[1].toUpperCase(), densityPct: Number(match[2]) }; diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index acacff5ce..b51776ff5 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -671,7 +671,8 @@ test('pathological detection paint holds alternate frames without freezing share test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => { const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); - const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8') + + '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/); assert.doesNotMatch(source, /postRender\.addEventListener/); assert.doesNotMatch(source, /['"]detection-overlay['"]/); @@ -694,7 +695,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle // three times" — same guarantee, and the copies can no longer drift. assert.match( uiSource, - /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/, + /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/, 'the tactical detection default is still Dense @ 75%', ); assert.equal( diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index b007128c8..c8ccff310 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -41,7 +41,9 @@ import { } from './scopeMask.js'; import { ShareLinkManager } from './sharelink.js'; -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +// Follow the UI wiring and its extracted preset definitions. +const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8') + + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); const indexHtml = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); @@ -210,7 +212,7 @@ test('first run opens with detection on, in every style, using the one tactical // Normal used to start OFF while only CRT/NVG/FLIR auto-applied the preset. // It is now the baseline for all of them, reusing the SAME frozen object, so // "the tactical look" cannot fork into two definitions. - assert.match(uiSource, /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/, + assert.match(uiSource, /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/, 'the tactical look is still Dense @ 75%'); const baseline = uiBlock('const GLOBAL_POST_DEFAULTS = {', '\n};'); assert.match(baseline, /detectionMode: MILITARY_DETECTION_PRESET\.mode\.toUpperCase\(\),/, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index c655401cf..e85ba2211 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -690,6 +690,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.ok(firstAwait > 0); const synchronous = disposal.slice(0, firstAwait); assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/); + assert.match(synchronous, /this\._visualEffects\.stop\(\)/); assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/); assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/); }); diff --git a/src/ui.js b/src/ui.js index e614eae70..5315fa4d9 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,18 +1,12 @@ +import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js'; import { bindDisplayControls } from './ui/displayControls.js'; 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'; -import { retroShader } from './styles/retro.js'; -import { animeShader } from './styles/anime.js'; -import { noirShader } from './styles/noir.js'; -import { snowShader } from './styles/snow.js'; -import { nightVisionShader } from './styles/surveillance.js'; -import { thermalShader } from './styles/thermal.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION, - bloomStrengthFromIntensity, clampBloomIntensity, decodeBloomIntensity, } from './bloom.js'; @@ -192,10 +186,6 @@ import { speedRulerTicks, } from './cockpitMath.js'; -/** Duration (ms) for shader intensity crossfade between style presets. */ -const TRANSITION_DURATION_MS = 500; -/** Map of style name to its GLSL shader module for post-process stages. */ -const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader }; /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; const SHARE_PANEL_STATE_SPECS = Object.freeze([ @@ -361,135 +351,13 @@ const STYLE_STATUS_LABELS = { noir: 'NOIR', snow: 'SNOW', }; -/** - * The tactical detection look: Dense at 75%. - * - * Owner playtest 2026-08-18: "detection mode… 75% weighted, with the 16% fade - * and 5% outside, whatever we had. I want that as the default. It should just - * happen." Fade and outside opacity live in GLOBAL_POST_DEFAULTS, so "whatever - * we had" still needs nothing here — but they are 7% and 1% now, the outside - * default having moved 5 → 3 → 1 as the owner locked final tuning after field trials (2026-08-24). What the quote asked for is the - * baseline of the day, not the two numbers it happened to name. - * - * ONE object, shared by the first-load baseline below, by every military style, - * AND by the Contacts context mode (which OWNS detection while active and - * restores the prior state on exit — see contactsDetectionPolicy.js). Cockpit - * deliberately does NOT touch detection: entering it with SPARSE selected leaves - * SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads - * from it. - */ -const MILITARY_DETECTION_PRESET = Object.freeze({ mode: 'dense', densityPct: 75 }); -/** Baseline post-processing settings applied on first load (before share-link restore). */ -const GLOBAL_POST_DEFAULTS = { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - hudVariant: 'tactical', - hudVisible: true, - // Detection is ON for EVERY style on a first run, Normal included (owner - // directive 2026-08-22: "detect should also be on by default"). It is the - // same preset object the military styles and Contacts already apply, so there - // is one tactical look, not several that can drift. - // - // This is a first-LOAD baseline, not an override: `_applyGlobalPostDefaults` - // runs before any share-link restore, so a link's `dm`/`dd` still lands on top - // of it. It also deliberately leaves `_detectionUserOverridden` alone — the - // flag means the OPERATOR hand-edited detection, and a factory default is not - // that. Turning detection off by hand therefore still sets the flag and still - // suppresses the military-style auto-enable for the rest of the session. - detectionMode: MILITARY_DETECTION_PRESET.mode.toUpperCase(), - detectionDensity: MILITARY_DETECTION_PRESET.densityPct, - detectionAllocation: 'ELASTIC', - detectionFadePct: 7, - detectionOutsideOpacityPct: 1, - celestialRing: false, -}; - -// Tactical style defaults applied when users select military style presets. -const STYLE_PRESET_DEFAULTS = { - retro: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - retro: { - pixelation: 1.0, - distortion: 0, - instability: 0.42, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, - surveillance: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - surveillance: { - gain: 0.18, - bloom: 0.22, - scanlineStr: 0.96, - pixelation: 1.0, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, - thermal: { - bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, - sharpen: { enabled: true, intensity: 49 }, - styleParams: { - thermal: { - sensitivity: 0.85, - bloom: 0.2, - mode: 0.33, - pixelation: 1.0, - }, - }, - hudVariant: 'tactical', - hudVisible: true, - detection: MILITARY_DETECTION_PRESET, - }, -}; - -/** - * GLSL fragment shader implementing an unsharp-mask sharpening filter. - * Samples a 3x3 neighborhood, computes box blur, then adds the - * difference (center - blur) scaled by `amount` for edge enhancement. - */ -const SHARPEN_SHADER = /* glsl */ ` - uniform sampler2D colorTexture; - uniform vec2 colorTextureDimensions; - uniform float amount; - in vec2 v_textureCoordinates; - - void main() { - vec2 uv = v_textureCoordinates; - vec2 texel = 1.0 / colorTextureDimensions; - vec4 center = texture(colorTexture, uv); - vec4 blur = ( - texture(colorTexture, uv + vec2(-texel.x, -texel.y)) + - texture(colorTexture, uv + vec2( 0.0, -texel.y)) + - texture(colorTexture, uv + vec2( texel.x, -texel.y)) + - texture(colorTexture, uv + vec2(-texel.x, 0.0)) + - center + - texture(colorTexture, uv + vec2( texel.x, 0.0)) + - texture(colorTexture, uv + vec2(-texel.x, texel.y)) + - texture(colorTexture, uv + vec2( 0.0, texel.y)) + - texture(colorTexture, uv + vec2( texel.x, texel.y)) - ) / 9.0; - vec4 sharpened = center + (center - blur) * amount; - out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a); - } -`; /** * Central UI orchestrator for the God's Eye View application. * * Responsibilities: - * - CesiumJS PostProcessStage pipeline: registers per-style GLSL stages - * (NVG, FLIR, CRT, anime, noir, snow) and manages intensity crossfades. + * - Visual controls and presets backed by the VisualEffects controller. * - Bloom and sharpen post-processing toggle/intensity control. * - Draggable/collapsible panel system with localStorage persistence, * z-order stacking, and viewport-clamped positioning. @@ -2190,26 +2058,24 @@ export class StyleManager { this.viewer = viewer; this.mapStackController = mapStackController; this.placeSearch = placeSearch; - this.stages = {}; + this._visualEffects = new VisualEffects({ + viewer, + requestRender: governorRequestRender, + holdRender: holdContinuousRender, + releaseRender: releaseContinuousRender, + }); this.activeStyle = 'normal'; document.documentElement.dataset.gevStyle = this.activeStyle; - this.transitions = new Map(); - this.startTime = Date.now(); // True once the user manually changes detection (button/key/slider/voice). // Gates per-style detection defaults so they never stomp an explicit choice. this._detectionUserOverridden = false; // Bloom/sharpen state - this.bloomEnabled = false; - this.sharpenEnabled = false; - this._bloomStage = null; - this._sharpenStage = null; this._recordingMode = false; this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' }; this._preRecordingHudState = null; this._panelZCounter = PANEL_Z_BASE + 10; - this._animFrameId = null; this._lastLoadingFeedbackUpdateAt = 0; this._loadingFeedbackState = createLoadingFeedbackState(); this._loadingFeedbackEvent = null; @@ -2830,6 +2696,15 @@ export class StyleManager { ); } + // Compatibility reads for existing controls, scene snapshots and Cockpit. + get stages() { return this._visualEffects.stages; } + get transitions() { return this._visualEffects.transitions; } + get bloomEnabled() { return this._visualEffects.bloomEnabled; } + get sharpenEnabled() { return this._visualEffects.sharpenEnabled; } + get _bloomStage() { return this._visualEffects.bloomStage; } + get _sharpenStage() { return this._visualEffects.sharpenStage; } + + /** Advance camera authority and settle any older search UI immediately. */ _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) { this._navigationGeneration += 1; @@ -3017,42 +2892,7 @@ export class StyleManager { * @returns {void} */ _initStages() { - for (const [name, shader] of Object.entries(STYLES)) { - const uniforms = { intensity: 0.0 }; - - // Auto-detect time uniform — animated shaders (CRT scanlines, snow, etc.) - // declare `uniform float time` and receive elapsed seconds each frame. - if (shader.fragmentShader.includes('uniform float time')) { - uniforms.time = 0.0; - } - - // Initialize custom uniforms from shader metadata (e.g. gain, pixelation) - if (shader.uniforms) { - for (const [uName, uMeta] of Object.entries(shader.uniforms)) { - uniforms[uName] = uMeta.default; - } - } - - const stage = new Cesium.PostProcessStage({ - name: `godsEyeView_${name}`, - fragmentShader: shader.fragmentShader, - uniforms, - }); - - // Zero-intensity stages are DISABLED (perf wave 1). History: the - // first attempt at this deleted the product's signature scope — the - // circular starfield mask was an EMERGENT artifact of these six - // stacked "identity" passes, not an implemented feature. The owner - // ruled to reimplement the scope explicitly (src/scopeMask.js, a - // featherable zero-per-frame canvas), which frees these passes for - // real. If the scope ever looks wrong, look there — not here. - stage.enabled = false; - this.viewer.scene.postProcessStages.add(stage); - this.stages[name] = stage; - } - // Frozen after init — cached so the per-frame animation loop doesn't - // rebuild Object.entries arrays every frame. - this._stageEntries = Object.entries(this.stages); + this._visualEffects.initStyles(); } /** @@ -3065,13 +2905,7 @@ export class StyleManager { * @returns {void} */ _setStageIntensity(stage, value) { - if (!stage) return; - stage.uniforms.intensity = value; - stage.enabled = value > 0.001; - // An animated shader becoming visible needs the style loop (its clock) - // running again; the loop self-stops when nothing visible animates. - if (stage.enabled && stage.uniforms.time !== undefined) this._startAnimationLoop(); - governorRequestRender('style-stage'); + this._visualEffects.setStageIntensity(stage, value); } /** @@ -3087,10 +2921,7 @@ export class StyleManager { * @returns {void} */ _syncStagesEnabledFromIntensity() { - if (!this.stages) return; - for (const stage of Object.values(this.stages)) { - this._setStageIntensity(stage, stage.uniforms.intensity); - } + this._visualEffects.syncStagesEnabledFromIntensity(); } /** @@ -3252,37 +3083,15 @@ export class StyleManager { * @returns {void} */ _initBloomSharpen() { - // Bloom — use Cesium's built-in bloom - this._bloomStage = this.viewer.scene.postProcessStages.bloom; - this._bloomStage.enabled = false; - this._bloomStage.uniforms.glowOnly = false; - this._bloomStage.uniforms.contrast = 256.0; - this._bloomStage.uniforms.brightness = -0.35; - this._bloomStage.uniforms.delta = 0.25; - this._bloomStage.uniforms.sigma = 0.35; - this._bloomStage.uniforms.stepSize = 1.0; - - // Sharpen — custom unsharp mask PostProcessStage - this._sharpenStage = new Cesium.PostProcessStage({ - name: 'godsEyeView_sharpen', - fragmentShader: SHARPEN_SHADER, - uniforms: { - amount: 1.3, - }, - }); - this._sharpenStage.enabled = false; - this.viewer.scene.postProcessStages.add(this._sharpenStage); - if (this._sharpenSlider) { - this._applySharpenIntensity(parseInt(this._sharpenSlider.value, 10) / 100); - } + this._visualEffects.initPostProcess(this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) / 100 : 0.6); } /** - * Reads the current bloom intensity percentage from the UI slider. + * Reads the current bloom intensity percentage from the effects controller. * @returns {number} Clamped bloom intensity (0-200). */ _getBloomIntensity() { - return clampBloomIntensity(parseInt(this._bloomSlider?.value || `${BLOOM_INTENSITY_DEFAULT}`, 10)); + return this._visualEffects.bloomIntensity; } /** @@ -3291,9 +3100,7 @@ export class StyleManager { * @returns {void} */ _syncBloomStageEnabled() { - if (!this._bloomStage) return; - const strength = bloomStrengthFromIntensity(this._getBloomIntensity()); - this._bloomStage.enabled = this.bloomEnabled && strength > 0.06; + this._visualEffects.syncBloomEnabled(); } /** @@ -3320,22 +3127,7 @@ export class StyleManager { * @returns {void} */ _applyBloomIntensity(intensity) { - if (!this._bloomStage) return; - const rawStrength = bloomStrengthFromIntensity(intensity); - // Dead-zone: strengths below 0.06 are imperceptible, clamp to zero. - const strength = rawStrength <= 0.06 ? 0.0 : ((rawStrength - 0.06) / 0.94); - // Smoothstep easing for perceptually linear bloom ramp - const eased = strength * strength * (3.0 - 2.0 * strength); - - // Mapping tuned for intuitive UX: - // 0 => effectively no glow, 200 => strong glow. - // Keep threshold strict at low values so only very bright highlights bloom. - this._bloomStage.uniforms.contrast = 255.0 - (eased * 168.0); - this._bloomStage.uniforms.brightness = -0.5 + (eased * 0.36); - this._bloomStage.uniforms.sigma = 0.28 + (eased * 6.3); - this._bloomStage.uniforms.delta = 0.2 + (eased * 2.25); - this._bloomStage.uniforms.stepSize = 1.0 + (eased * 1.25); - this._syncBloomStageEnabled(); + this._visualEffects.applyBloomIntensity(intensity); } /** @@ -3345,7 +3137,7 @@ export class StyleManager { */ _setBloomEnabled(enabled) { governorRequestRender('bloom'); - this.bloomEnabled = !!enabled; + this._visualEffects.setBloomEnabled(enabled); this._syncBloomStageEnabled(); this._bloomBtn.classList.toggle('active', this.bloomEnabled); this._bloomSliderRow.classList.toggle('visible', this.bloomEnabled); @@ -3363,9 +3155,7 @@ export class StyleManager { * @returns {void} */ _applySharpenIntensity(val) { - governorRequestRender('sharpen'); - if (!this._sharpenStage || !this._sharpenStage.uniforms) return; - this._sharpenStage.uniforms.amount = 0.1 + val * 2.0; + this._visualEffects.applySharpenIntensity(val); } /** @@ -3375,8 +3165,7 @@ export class StyleManager { */ _setSharpenEnabled(enabled) { governorRequestRender('sharpen'); - this.sharpenEnabled = !!enabled; - this._sharpenStage.enabled = this.sharpenEnabled; + this._visualEffects.setSharpenEnabled(enabled); this._sharpenBtn.classList.toggle('active', this.sharpenEnabled); if (this._sharpenSliderRow) { this._sharpenSliderRow.classList.toggle('visible', this.sharpenEnabled); @@ -8624,12 +8413,7 @@ export class StyleManager { * @returns {void} */ _startTransition(styleName, fromValue, toValue) { - this.transitions.set(styleName, { - start: performance.now(), - from: fromValue, - to: toValue, - }); - this._startAnimationLoop(); + this._visualEffects.startTransition(styleName, fromValue, toValue); } /** @@ -8699,53 +8483,7 @@ export class StyleManager { * interval (see _startTrafficChipTicker). */ _startAnimationLoop() { - if (this._animFrameId) return; // already running - const update = () => { - const now = performance.now(); - const elapsedSec = (Date.now() - this.startTime) / 1000.0; - - // Update transitions — interpolate each active crossfade - for (const [styleName, transition] of this.transitions) { - const elapsed = now - transition.start; - const t = Math.min(elapsed / TRANSITION_DURATION_MS, 1.0); - // Ease-in-out quadratic: smooth acceleration then deceleration - const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; - const value = transition.from + (transition.to - transition.from) * eased; - - this._setStageIntensity(this.stages[styleName], value); - - if (t >= 1.0) { - this._setStageIntensity(this.stages[styleName], transition.to); - this.transitions.delete(styleName); - } - } - - // Update time uniforms for animated shaders. Zero-intensity stages - // are disabled (see _initStages — the scope is now the explicit - // scopeMask canvas), so enabled === visible here; only these keep - // the loop and its continuous-render hold alive. - let animatedStageVisible = false; - for (const [, stage] of this._stageEntries) { - if (stage.enabled && stage.uniforms.time !== undefined) { - stage.uniforms.time = elapsedSec; - // Chain mode keeps zero-intensity stages ENABLED for pass parity — - // only a stage that is actually VISIBLE keeps the loop (and the - // continuous-render hold) alive, or a settled CRT session would - // hold the loop forever via an invisible snow stage. - if (stage.uniforms.intensity > 0.001) animatedStageVisible = true; - } - } - - const needed = this.transitions.size > 0 || animatedStageVisible; - if (needed) holdContinuousRender('style-anim'); - else releaseContinuousRender('style-anim'); - if (!needed) { - this._animFrameId = null; - return; // settled — the next transition/animated stage re-arms us - } - this._animFrameId = requestAnimationFrame(update); - }; - this._animFrameId = requestAnimationFrame(update); + this._visualEffects.startAnimationLoop(); } /** @@ -9663,6 +9401,7 @@ export class StyleManager { this._disposed = true; this._applicationShortcuts?.destroy(); this._displayControls?.destroy(); + this._visualEffects.stop(); this._styleParameters?.destroy(); for (const control of this._panelDisclosureControls || []) control.destroy(); this._panelDisclosureControls = []; @@ -9777,13 +9516,8 @@ export class StyleManager { document.removeEventListener('keydown', this._poiKeydownHandler); this._poiKeydownHandler = null; } - // Cancel the rAF animation loop and release its governor hold; also stop - // the traffic-chip ticker the loop no longer carries. (perf wave 2 fix) - if (this._animFrameId) { - cancelAnimationFrame(this._animFrameId); - this._animFrameId = null; - } - releaseContinuousRender('style-anim'); + // Stop the independently owned traffic and loading feedback tickers. + if (this._trafficChipTicker) { clearInterval(this._trafficChipTicker); this._trafficChipTicker = null; @@ -9847,7 +9581,6 @@ export class StyleManager { destroyTrackedReadout(); destroyDetection(); destroyWorldOverlay(); - // Clear transitions - this.transitions.clear(); + this._visualEffects.destroy(); } } diff --git a/src/ui/effects.js b/src/ui/effects.js new file mode 100644 index 000000000..d215f3500 --- /dev/null +++ b/src/ui/effects.js @@ -0,0 +1,2 @@ +export { VisualEffects } from './visualEffects.js'; +export { STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js'; diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js new file mode 100644 index 000000000..b0b54ba0b --- /dev/null +++ b/src/ui/visualEffects.js @@ -0,0 +1,182 @@ +import { PostProcessStage } from 'cesium'; +import { bloomStrengthFromIntensity, clampBloomIntensity, BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; +import { STYLES, SHARPEN_SHADER, TRANSITION_DURATION_MS } from './visualPresets.js'; + +/** Own the post-process stages and their animation, without DOM dependencies. */ +export class VisualEffects { + /** + * @param {object} options - Viewer, render ownership callbacks and optional clocks. + * Construction is inert; initStyles/initPostProcess retain startup ordering. + */ + constructor({ viewer, requestRender, holdRender, releaseRender, + requestFrame = (callback) => requestAnimationFrame(callback), + cancelFrame = (id) => cancelAnimationFrame(id), + now = () => performance.now(), wallNow = () => Date.now(), + createStage = (options) => new PostProcessStage(options), + }) { + this.viewer = viewer; + this.requestRender = requestRender; + this.holdRender = holdRender; + this.releaseRender = releaseRender; + this.requestFrame = requestFrame; + this.cancelFrame = cancelFrame; + this.now = now; + this.wallNow = wallNow; + this.createStage = createStage; + this.stages = {}; + this.transitions = new Map(); + this.startTime = wallNow(); + this.bloomEnabled = false; + this.sharpenEnabled = false; + this.bloomIntensity = BLOOM_INTENSITY_DEFAULT; + this.sharpenIntensity = 0.49; + this.bloomStage = null; + this.sharpenStage = null; + this.frameId = null; + this.stopped = false; + this.destroyed = false; + this.stageEntries = []; + this.previousBloom = null; + } + + initStyles() { + if (this.stopped || this.stageEntries.length) return; + for (const [name, shader] of Object.entries(STYLES)) { + const uniforms = { intensity: 0.0 }; + if (shader.fragmentShader.includes('uniform float time')) uniforms.time = 0.0; + for (const [name, meta] of Object.entries(shader.uniforms || {})) uniforms[name] = meta.default; + const stage = this.createStage({ name: `godsEyeView_${name}`, fragmentShader: shader.fragmentShader, uniforms }); + stage.enabled = false; + this.viewer.scene.postProcessStages.add(stage); + this.stages[name] = stage; + } + this.stageEntries = Object.entries(this.stages); + } + + initPostProcess(sharpenIntensity = this.sharpenIntensity) { + if (this.stopped || this.sharpenStage) return; + this.bloomStage = this.viewer.scene.postProcessStages.bloom; + const names = ['glowOnly', 'contrast', 'brightness', 'delta', 'sigma', 'stepSize']; + this.previousBloom = { enabled: this.bloomStage.enabled, uniforms: Object.fromEntries(names.map((name) => [name, this.bloomStage.uniforms[name]])) }; + this.bloomStage.enabled = false; + Object.assign(this.bloomStage.uniforms, { glowOnly: false, contrast: 256.0, brightness: -0.35, delta: 0.25, sigma: 0.35, stepSize: 1.0 }); + this.sharpenStage = this.createStage({ name: 'godsEyeView_sharpen', fragmentShader: SHARPEN_SHADER, uniforms: { amount: 1.3 } }); + this.sharpenStage.enabled = false; + this.viewer.scene.postProcessStages.add(this.sharpenStage); + this.applySharpenIntensity(sharpenIntensity); + } + + setStageIntensity(stage, value) { + if (this.stopped || !stage) return; + stage.uniforms.intensity = value; + stage.enabled = value > 0.001; + if (stage.enabled && stage.uniforms.time !== undefined) this.startAnimationLoop(); + this.requestRender('style-stage'); + } + + syncStagesEnabledFromIntensity() { + for (const [, stage] of this.stageEntries) this.setStageIntensity(stage, stage.uniforms.intensity); + } + + syncBloomEnabled() { + if (this.stopped || !this.bloomStage) return; + this.bloomStage.enabled = this.bloomEnabled && bloomStrengthFromIntensity(this.bloomIntensity) > 0.06; + } + + applyBloomIntensity(intensity) { + if (this.stopped) return; + this.bloomIntensity = clampBloomIntensity(intensity); + this.requestRender('bloom'); + if (!this.bloomStage) return; + const rawStrength = bloomStrengthFromIntensity(this.bloomIntensity); + const strength = rawStrength <= 0.06 ? 0.0 : ((rawStrength - 0.06) / 0.94); + const eased = strength * strength * (3.0 - 2.0 * strength); + this.bloomStage.uniforms.contrast = 255.0 - eased * 168.0; + this.bloomStage.uniforms.brightness = -0.5 + eased * 0.36; + this.bloomStage.uniforms.sigma = 0.28 + eased * 6.3; + this.bloomStage.uniforms.delta = 0.2 + eased * 2.25; + this.bloomStage.uniforms.stepSize = 1.0 + eased * 1.25; + this.syncBloomEnabled(); + } + + setBloomEnabled(enabled) { + if (this.stopped) return; + this.bloomEnabled = !!enabled; + this.syncBloomEnabled(); + this.requestRender('bloom'); + } + + applySharpenIntensity(value) { + if (this.stopped) return; + this.sharpenIntensity = value; + if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; + this.requestRender('sharpen'); + } + + setSharpenEnabled(enabled) { + if (this.stopped) return; + this.sharpenEnabled = !!enabled; + if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; + this.requestRender('sharpen'); + } + + startTransition(styleName, from, to) { + if (this.stopped) return; + this.transitions.set(styleName, { start: this.now(), from, to }); + this.startAnimationLoop(); + } + + startAnimationLoop() { + if (this.stopped || this.frameId !== null) return; + const update = () => { + if (this.stopped) return; + const now = this.now(); + const elapsedSec = (this.wallNow() - this.startTime) / 1000.0; + for (const [name, transition] of this.transitions) { + const t = Math.min((now - transition.start) / TRANSITION_DURATION_MS, 1.0); + const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + this.setStageIntensity(this.stages[name], transition.from + (transition.to - transition.from) * eased); + if (t >= 1.0) { + this.setStageIntensity(this.stages[name], transition.to); + this.transitions.delete(name); + } + } + let animatedStageVisible = false; + for (const [, stage] of this.stageEntries) { + if (stage.enabled && stage.uniforms.time !== undefined) { + stage.uniforms.time = elapsedSec; + if (stage.uniforms.intensity > 0.001) animatedStageVisible = true; + } + } + const needed = this.transitions.size > 0 || animatedStageVisible; + if (needed) this.holdRender('style-anim'); + else this.releaseRender('style-anim'); + this.frameId = needed ? this.requestFrame(update) : null; + }; + this.frameId = this.requestFrame(update); + } + + /** Revoke animation synchronously while callers finish releasing the stages. */ + stop() { + if (this.stopped) return; + this.stopped = true; + if (this.frameId !== null) this.cancelFrame(this.frameId); + this.frameId = null; + this.releaseRender('style-anim'); + this.transitions.clear(); + } + + /** Remove owned stages and restore the borrowed bloom stage after callers release it. */ + destroy() { + if (this.destroyed) return; + this.stop(); + this.destroyed = true; + const stages = this.viewer.scene.postProcessStages; + for (const [, stage] of this.stageEntries) stages.remove(stage); + if (this.sharpenStage) stages.remove(this.sharpenStage); + if (this.bloomStage && this.previousBloom) { + Object.assign(this.bloomStage.uniforms, this.previousBloom.uniforms); + this.bloomStage.enabled = this.previousBloom.enabled; + } + } +} diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs new file mode 100644 index 000000000..f28288e52 --- /dev/null +++ b/src/ui/visualEffects.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { VisualEffects } from './visualEffects.js'; +import { GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js'; + +function fixture() { + let time = 0; + let next = 0; + const frames = new Map(); + const stages = new Set(); + const holds = new Set(); + const requests = []; + const bloom = { enabled: true, uniforms: { glowOnly: true, contrast: 3, brightness: 4, delta: 5, sigma: 6, stepSize: 7 } }; + const originalBloom = structuredClone(bloom); + const pipeline = { bloom, add(stage) { stages.add(stage); }, remove(stage) { return stages.delete(stage); } }; + const effects = new VisualEffects({ + viewer: { scene: { postProcessStages: pipeline } }, + requestRender: (reason) => requests.push(reason), + holdRender: (reason) => holds.add(reason), releaseRender: (reason) => holds.delete(reason), + requestFrame: (callback) => { const id = next++; frames.set(id, callback); return id; }, + cancelFrame: (id) => frames.delete(id), now: () => time, wallNow: () => 10_000 + time, + createStage: (options) => ({ ...options, enabled: true }), + }); + function tick(value) { + time = value; + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(); + } + return { effects, frames, stages, holds, requests, bloom, originalBloom, tick }; +} + +test('construction is inert and initialization creates one owned pipeline', () => { + const f = fixture(); + assert.equal(f.stages.size, 0); + assert.equal(f.frames.size, 0); + f.effects.initStyles(); + f.effects.initPostProcess(); + assert.equal(f.stages.size, 7); + assert.ok(Object.values(f.effects.stages).every((stage) => !stage.enabled && stage.uniforms.intensity === 0)); + f.effects.initStyles(); + f.effects.initPostProcess(); + assert.equal(f.stages.size, 7); + f.effects.destroy(); +}); + +test('crossfade uses the established 500ms easing and settles without idle frames', () => { + const f = fixture(); + f.effects.initStyles(); + f.effects.startTransition('noir', 0, 1); + assert.equal(f.effects.frameId, 0, 'zero is a valid pending animation id'); + f.effects.startAnimationLoop(); + assert.equal(f.frames.size, 1, 'starting twice cannot schedule duplicate frames'); + f.tick(125); + assert.equal(f.effects.stages.noir.uniforms.intensity, 0.125); + f.tick(250); + assert.equal(f.effects.stages.noir.uniforms.intensity, 0.5); + f.tick(500); + assert.equal(f.effects.stages.noir.uniforms.intensity, 1); + assert.equal(f.effects.transitions.size, 0); + // Noir's shader has no animated time uniform. + assert.equal(f.frames.size, 0); + assert.equal(f.holds.size, 0); + f.effects.destroy(); +}); + +test('a superseding transition starts from the current rendered intensity', () => { + const f = fixture(); + f.effects.initStyles(); + f.effects.startTransition('noir', 0, 1); + f.tick(250); + f.effects.startTransition('noir', f.effects.stages.noir.uniforms.intensity, 0); + f.tick(500); + assert.equal(f.effects.stages.noir.uniforms.intensity, 0.25); + f.tick(750); + assert.equal(f.effects.stages.noir.uniforms.intensity, 0); + assert.equal(f.effects.stages.noir.enabled, false); + assert.equal(f.frames.size, 0); + f.effects.destroy(); +}); + +test('only visible animated stages keep the animation clock and render hold alive', () => { + const f = fixture(); + f.effects.initStyles(); + const animated = f.effects.stages.retro; + assert.notEqual(animated.uniforms.time, undefined); + f.effects.setStageIntensity(animated, 1); + f.tick(1000); + assert.equal(animated.uniforms.time, 1); + assert.equal(f.frames.size, 1); + assert.equal(f.holds.size, 1); + f.effects.setStageIntensity(animated, 0); + f.tick(1100); + assert.equal(f.frames.size, 0); + assert.equal(f.holds.size, 0); + f.effects.destroy(); +}); + +test('direct Cockpit intensity writes are reconciled before rendering', () => { + const f = fixture(); + f.effects.initStyles(); + f.effects.stages.thermal.uniforms.intensity = 1; + f.effects.syncStagesEnabledFromIntensity(); + assert.equal(f.effects.stages.thermal.enabled, true); + f.effects.stages.thermal.uniforms.intensity = 0; + f.effects.syncStagesEnabledFromIntensity(); + assert.equal(f.effects.stages.thermal.enabled, false); + f.effects.destroy(); +}); + +test('bloom retains its dead zone, normalized intensity and independent toggle', () => { + const f = fixture(); + f.effects.initPostProcess(); + f.effects.setBloomEnabled(true); + f.effects.applyBloomIntensity(0); + assert.equal(f.bloom.enabled, false); + f.effects.applyBloomIntensity(200); + assert.equal(f.bloom.enabled, true); + assert.equal(f.bloom.uniforms.contrast, 87); + f.effects.setBloomEnabled(false); + assert.equal(f.effects.bloomIntensity, 200); + assert.equal(f.bloom.enabled, false); + f.effects.applyBloomIntensity(300); + assert.equal(f.effects.bloomIntensity, 200); + f.effects.destroy(); +}); + +test('sharpen uses the existing uniform scale and keeps values while disabled', () => { + const f = fixture(); + f.effects.initPostProcess(0.49); + assert.equal(f.effects.sharpenStage.uniforms.amount, 1.08); + f.effects.applySharpenIntensity(1); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.uniforms.amount, 2.1); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.setSharpenEnabled(false); + assert.equal(f.effects.sharpenIntensity, 1); + assert.equal(f.effects.sharpenStage.enabled, false); + f.effects.destroy(); +}); + +test('stop revokes pending work immediately but retains stages until final destruction', () => { + const f = fixture(); + f.effects.initStyles(); + f.effects.initPostProcess(); + f.effects.startTransition('retro', 0, 1); + const staleFrame = [...f.frames.values()][0]; + f.effects.stop(); + assert.equal(f.frames.size, 0); + assert.equal(f.stages.size, 7, 'Context and Cockpit may still be releasing these stages'); + const intensity = f.effects.stages.retro.uniforms.intensity; + staleFrame(); + f.effects.startTransition('retro', 0, 1); + f.effects.setStageIntensity(f.effects.stages.retro, 1); + assert.equal(f.frames.size, 0); + assert.equal(f.effects.stages.retro.uniforms.intensity, intensity); + f.effects.destroy(); + assert.equal(f.stages.size, 0); + assert.deepEqual(f.bloom, f.originalBloom, 'borrowed bloom configuration is restored'); + f.effects.destroy(); +}); + +test('destroying one instance does not remove another pipeline or clock', () => { + const a = fixture(); + const b = fixture(); + for (const f of [a, b]) { f.effects.initStyles(); f.effects.startTransition('retro', 0, 1); } + a.effects.destroy(); + b.tick(250); + assert.equal(a.stages.size, 0); + assert.equal(b.stages.size, 6); + assert.equal(b.effects.stages.retro.uniforms.intensity, 0.5); + b.effects.destroy(); +}); + +test('existing baseline and military presets keep one detection default', () => { + assert.equal(GLOBAL_POST_DEFAULTS.detectionMode, 'DENSE'); + assert.equal(GLOBAL_POST_DEFAULTS.detectionDensity, 75); + assert.equal(GLOBAL_POST_DEFAULTS.sharpen.intensity, 49); + assert.equal(GLOBAL_POST_DEFAULTS.detectionFadePct, 7); + assert.equal(GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct, 1); + for (const name of ['retro', 'surveillance', 'thermal']) { + assert.equal(STYLE_PRESET_DEFAULTS[name].detection, MILITARY_DETECTION_PRESET); + } + assert.equal(STYLE_PRESET_DEFAULTS.normal, undefined); +}); diff --git a/src/ui/visualPresets.js b/src/ui/visualPresets.js new file mode 100644 index 000000000..a1415efcc --- /dev/null +++ b/src/ui/visualPresets.js @@ -0,0 +1,135 @@ +import { retroShader } from '../styles/retro.js'; +import { animeShader } from '../styles/anime.js'; +import { noirShader } from '../styles/noir.js'; +import { snowShader } from '../styles/snow.js'; +import { nightVisionShader } from '../styles/surveillance.js'; +import { thermalShader } from '../styles/thermal.js'; +import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; + +/** Duration (ms) for shader intensity crossfade between style presets. */ +export const TRANSITION_DURATION_MS = 500; +/** Map of style name to its GLSL shader module for post-process stages. */ +export const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader }; + +/** + * The tactical detection look: Dense at 75%. + * + * Owner playtest 2026-08-18: "detection mode… 75% weighted, with the 16% fade + * and 5% outside, whatever we had. I want that as the default. It should just + * happen." Fade and outside opacity live in GLOBAL_POST_DEFAULTS, so "whatever + * we had" still needs nothing here — but they are 7% and 1% now, the outside + * default having moved 5 → 3 → 1 as the owner locked final tuning after field trials (2026-08-24). What the quote asked for is the + * baseline of the day, not the two numbers it happened to name. + * + * ONE object, shared by the first-load baseline below, by every military style, + * AND by the Contacts context mode (which OWNS detection while active and + * restores the prior state on exit — see contactsDetectionPolicy.js). Cockpit + * deliberately does NOT touch detection: entering it with SPARSE selected leaves + * SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads + * from it. + */ +export const MILITARY_DETECTION_PRESET = Object.freeze({ mode: 'dense', densityPct: 75 }); + +/** Baseline post-processing settings applied on first load (before share-link restore). */ +export const GLOBAL_POST_DEFAULTS = { + bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, + sharpen: { enabled: true, intensity: 49 }, + hudVariant: 'tactical', + hudVisible: true, + // Detection is ON for EVERY style on a first run, Normal included (owner + // directive 2026-08-22: "detect should also be on by default"). It is the + // same preset object the military styles and Contacts already apply, so there + // is one tactical look, not several that can drift. + // + // This is a first-LOAD baseline, not an override: `_applyGlobalPostDefaults` + // runs before any share-link restore, so a link's `dm`/`dd` still lands on top + // of it. It also deliberately leaves `_detectionUserOverridden` alone — the + // flag means the OPERATOR hand-edited detection, and a factory default is not + // that. Turning detection off by hand therefore still sets the flag and still + // suppresses the military-style auto-enable for the rest of the session. + detectionMode: MILITARY_DETECTION_PRESET.mode.toUpperCase(), + detectionDensity: MILITARY_DETECTION_PRESET.densityPct, + detectionAllocation: 'ELASTIC', + detectionFadePct: 7, + detectionOutsideOpacityPct: 1, + celestialRing: false, +}; + +// Tactical style defaults applied when users select military style presets. +export const STYLE_PRESET_DEFAULTS = { + retro: { + bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, + sharpen: { enabled: true, intensity: 49 }, + styleParams: { + retro: { + pixelation: 1.0, + distortion: 0, + instability: 0.42, + }, + }, + hudVariant: 'tactical', + hudVisible: true, + detection: MILITARY_DETECTION_PRESET, + }, + surveillance: { + bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, + sharpen: { enabled: true, intensity: 49 }, + styleParams: { + surveillance: { + gain: 0.18, + bloom: 0.22, + scanlineStr: 0.96, + pixelation: 1.0, + }, + }, + hudVariant: 'tactical', + hudVisible: true, + detection: MILITARY_DETECTION_PRESET, + }, + thermal: { + bloom: { enabled: false, intensity: BLOOM_INTENSITY_DEFAULT }, + sharpen: { enabled: true, intensity: 49 }, + styleParams: { + thermal: { + sensitivity: 0.85, + bloom: 0.2, + mode: 0.33, + pixelation: 1.0, + }, + }, + hudVariant: 'tactical', + hudVisible: true, + detection: MILITARY_DETECTION_PRESET, + }, +}; + +/** + * GLSL fragment shader implementing an unsharp-mask sharpening filter. + * Samples a 3x3 neighborhood, computes box blur, then adds the + * difference (center - blur) scaled by `amount` for edge enhancement. + */ +export const SHARPEN_SHADER = /* glsl */ ` + uniform sampler2D colorTexture; + uniform vec2 colorTextureDimensions; + uniform float amount; + in vec2 v_textureCoordinates; + + void main() { + vec2 uv = v_textureCoordinates; + vec2 texel = 1.0 / colorTextureDimensions; + vec4 center = texture(colorTexture, uv); + vec4 blur = ( + texture(colorTexture, uv + vec2(-texel.x, -texel.y)) + + texture(colorTexture, uv + vec2( 0.0, -texel.y)) + + texture(colorTexture, uv + vec2( texel.x, -texel.y)) + + texture(colorTexture, uv + vec2(-texel.x, 0.0)) + + center + + texture(colorTexture, uv + vec2( texel.x, 0.0)) + + texture(colorTexture, uv + vec2(-texel.x, texel.y)) + + texture(colorTexture, uv + vec2( 0.0, texel.y)) + + texture(colorTexture, uv + vec2( texel.x, texel.y)) + ) / 9.0; + vec4 sharpened = center + (center - blur) * amount; + out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a); + } +`; From 1bbb6d9c70e634a4151d3785b64d35546299e2ba Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 21:55:18 -0700 Subject: [PATCH 02/92] style: format visual effects and shader definitions --- scripts/format-scope.json | 14 ++- scripts/qa-visual-effects.mjs | 210 +++++++++++++++++++++++++++------- src/bloom.js | 11 +- src/styles/surveillance.js | 2 +- src/ui/effects.js | 7 +- src/ui/visualEffects.js | 89 +++++++++++--- src/ui/visualEffects.test.mjs | 92 ++++++++++++--- src/ui/visualPresets.js | 14 ++- 8 files changed, 356 insertions(+), 83 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7f8717b16..3409bdecf 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -136,5 +136,17 @@ "src/ui/visualInput.test.mjs", "scripts/qa-visual-input.mjs", "src/ui/displayControls.js", - "src/ui/displayControls.test.mjs" + "src/ui/displayControls.test.mjs", + "src/ui/effects.js", + "src/ui/visualEffects.js", + "src/ui/visualPresets.js", + "src/ui/visualEffects.test.mjs", + "src/bloom.js", + "src/styles/retro.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/snow.js", + "scripts/qa-visual-effects.mjs" ] diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs index b2a5fe656..e51e5b01f 100644 --- a/scripts/qa-visual-effects.mjs +++ b/scripts/qa-visual-effects.mjs @@ -3,7 +3,15 @@ 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 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)); @@ -17,67 +25,165 @@ try { 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?._visualEffects && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60_000 }); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._visualEffects && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60_000 }, + ); await page.evaluate(() => { const viewer = window.__godsEyeView.viewer; viewer.camera.cancelFlight?.(); viewer.scene.tweens?.removeAll?.(); viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ longitude: -97.7431 * Math.PI / 180, latitude: 30.2568 * Math.PI / 180, height: 1200 }), + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (-97.7431 * Math.PI) / 180, + latitude: (30.2568 * Math.PI) / 180, + height: 1200, + }), orientation: { heading: 0, pitch: -0.85, roll: 0 }, }); viewer.scene.requestRender(); }); - await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); - const settled = await page.waitForFunction(() => { - const primitives = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < primitives.length; i++) { - const primitive = primitives.get(i); - if (typeof primitive.tilesLoaded === 'boolean' && !primitive.tilesLoaded) return false; - } - return true; - }, { timeout: 60_000 }).then(() => true, () => false); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + const settled = await page + .waitForFunction( + () => { + const primitives = window.__godsEyeView.viewer.scene.primitives; + for (let i = 0; i < primitives.length; i++) { + const primitive = primitives.get(i); + if ( + typeof primitive.tilesLoaded === 'boolean' && + !primitive.tilesLoaded + ) + return false; + } + return true; + }, + { timeout: 60_000 }, + ) + .then( + () => true, + () => false, + ); check('visible tile content settles before visual captures', settled); fs.mkdirSync('qa-shots/visual-effects', { recursive: true }); - for (const style of ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'normal']) { - await page.evaluate((name) => window.__godsEyeView.styleManager.setStyle(name), style); - await page.waitForFunction(() => window.__godsEyeView.styleManager.transitions.size === 0, { timeout: 10_000 }); + for (const style of [ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + 'normal', + ]) { + await page.evaluate( + (name) => window.__godsEyeView.styleManager.setStyle(name), + style, + ); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + { timeout: 10_000 }, + ); const state = await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; const effects = manager._visualEffects; - const visible = Object.entries(effects.stages).filter(([, stage]) => stage.enabled).map(([name]) => name); - const animated = Object.values(effects.stages).some((stage) => stage.enabled && stage.uniforms.time !== undefined && stage.uniforms.intensity > 0.001); - return { active: manager.activeStyle, visible, clock: effects.frameId !== null, animated }; + const visible = Object.entries(effects.stages) + .filter(([, stage]) => stage.enabled) + .map(([name]) => name); + const animated = Object.values(effects.stages).some( + (stage) => + stage.enabled && + stage.uniforms.time !== undefined && + stage.uniforms.intensity > 0.001, + ); + return { + active: manager.activeStyle, + visible, + clock: effects.frameId !== null, + animated, + }; }); - check(`${style}: only the selected style remains visible`, state.active === style && JSON.stringify(state.visible) === JSON.stringify(style === 'normal' ? [] : [style])); - check(`${style}: the animation clock follows visible animated stages`, state.clock === state.animated); + check( + `${style}: only the selected style remains visible`, + state.active === style && + JSON.stringify(state.visible) === + JSON.stringify(style === 'normal' ? [] : [style]), + ); + check( + `${style}: the animation clock follows visible animated stages`, + state.clock === state.animated, + ); if (['normal', 'surveillance', 'thermal'].includes(style)) { await page.screenshot({ path: `qa-shots/visual-effects/${style}.png` }); - if (style === 'surveillance') await page.evaluate(() => window.__godsEyeView.viewer.camera.lookRight(0.2)); + if (style === 'surveillance') + await page.evaluate(() => + window.__godsEyeView.viewer.camera.lookRight(0.2), + ); } } await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; - manager.setStyle('noir'); manager.setStyle('retro'); manager.setStyle('normal'); + manager.setStyle('noir'); + manager.setStyle('retro'); + manager.setStyle('normal'); }); - await page.waitForFunction(() => window.__godsEyeView.styleManager.transitions.size === 0, { timeout: 10_000 }); - check('rapid switches settle to Normal without an old style or clock', await page.evaluate(() => { - const effects = window.__godsEyeView.styleManager._visualEffects; - return effects.frameId === null && Object.values(effects.stages).every((stage) => !stage.enabled); - })); - check('bloom actions, effect state and saved snapshot agree', await page.evaluate(() => { - const manager = window.__godsEyeView.styleManager; - const result = manager.setBloom({ enabled: true, intensityPct: 98 }); - const snapshot = manager.getVisualState(); - return result.ok && result.bloom.intensityPct === 98 && snapshot.bloom.intensity === 98 && manager._visualEffects.bloomIntensity === 98 && manager._bloomStage.enabled; - })); - check('sharpen actions, effect state and saved snapshot agree', await page.evaluate(() => { - const manager = window.__godsEyeView.styleManager; - const result = manager.setSharpen({ enabled: true, intensityPct: 61 }); - return result.ok && result.sharpen.intensityPct === 61 && manager.getVisualState().sharpen.intensity === 61 && manager._visualEffects.sharpenIntensity === 0.61 && Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9; - })); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + { timeout: 10_000 }, + ); + check( + 'rapid switches settle to Normal without an old style or clock', + await page.evaluate(() => { + const effects = window.__godsEyeView.styleManager._visualEffects; + return ( + effects.frameId === null && + Object.values(effects.stages).every((stage) => !stage.enabled) + ); + }), + ); + check( + 'bloom actions, effect state and saved snapshot agree', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const result = manager.setBloom({ enabled: true, intensityPct: 98 }); + const snapshot = manager.getVisualState(); + return ( + result.ok && + result.bloom.intensityPct === 98 && + snapshot.bloom.intensity === 98 && + manager._visualEffects.bloomIntensity === 98 && + manager._bloomStage.enabled + ); + }), + ); + check( + 'sharpen actions, effect state and saved snapshot agree', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const result = manager.setSharpen({ enabled: true, intensityPct: 61 }); + return ( + result.ok && + result.sharpen.intensityPct === 61 && + manager.getVisualState().sharpen.intensity === 61 && + manager._visualEffects.sharpenIntensity === 0.61 && + Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9 + ); + }), + ); await page.setViewport({ width: 620, height: 900 }); - await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { persist: false, syncShare: false })); + await page.evaluate(() => + window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }), + ); await page.screenshot({ path: 'qa-shots/visual-effects/narrow.png' }); const teardown = await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; @@ -87,17 +193,33 @@ try { const collection = manager.viewer.scene.postProcessStages; const previousBloom = effects.previousBloom; const pending = manager.dispose(); - const stoppedBeforeAwait = effects.stopped && effects.frameId === null && effects.transitions.size === 0; + const stoppedBeforeAwait = + effects.stopped && + effects.frameId === null && + effects.transitions.size === 0; await pending; return { stoppedBeforeAwait, removed: owned.every((stage) => !collection.contains(stage)), - restored: effects.bloomStage.enabled === previousBloom.enabled && Object.entries(previousBloom.uniforms).every(([name, value]) => effects.bloomStage.uniforms[name] === value), + restored: + effects.bloomStage.enabled === previousBloom.enabled && + Object.entries(previousBloom.uniforms).every( + ([name, value]) => effects.bloomStage.uniforms[name] === value, + ), }; }); - check('UI teardown stops effect animation before its first await', teardown.stoppedBeforeAwait); - check('UI teardown removes owned stages and restores borrowed bloom', teardown.removed && teardown.restored); - check('effects do not introduce uncaught browser errors', errors.length === 0); + check( + 'UI teardown stops effect animation before its first await', + teardown.stoppedBeforeAwait, + ); + check( + 'UI teardown removes owned stages and restores borrowed bloom', + teardown.removed && teardown.restored, + ); + check( + 'effects do not introduce uncaught browser errors', + errors.length === 0, + ); if (errors.length) console.log(JSON.stringify(errors)); } finally { await browser.close(); diff --git a/src/bloom.js b/src/bloom.js index 297d93ee6..fba0472d2 100644 --- a/src/bloom.js +++ b/src/bloom.js @@ -43,7 +43,11 @@ export function clampBloomIntensity(value) { * @returns {number} equivalent v2 intensity */ export function legacyBloomToV2(value) { - const legacy = clamp(Math.round(Number(value) || 0), 0, LEGACY_BLOOM_INTENSITY_MAX); + const legacy = clamp( + Math.round(Number(value) || 0), + 0, + LEGACY_BLOOM_INTENSITY_MAX, + ); return clampBloomIntensity((LEGACY_BLOOM_INTENSITY_MAX - legacy) * 2); } @@ -59,7 +63,10 @@ export function decodeBloomIntensity(value, version = 1) { if (!Number.isFinite(num)) return BLOOM_INTENSITY_DEFAULT; const normalizedVersion = Number(version); - if (Number.isFinite(normalizedVersion) && normalizedVersion >= BLOOM_SCALE_VERSION) { + if ( + Number.isFinite(normalizedVersion) && + normalizedVersion >= BLOOM_SCALE_VERSION + ) { return clampBloomIntensity(num); } diff --git a/src/styles/surveillance.js b/src/styles/surveillance.js index 21a3cd190..8ab7eb914 100644 --- a/src/styles/surveillance.js +++ b/src/styles/surveillance.js @@ -13,7 +13,7 @@ export const nightVisionShader = { name: 'surveillance', uniforms: { gain: { default: 0.55, min: 0, max: 1, label: 'Gain' }, - bloom: { default: 0.30, min: 0, max: 1, label: 'Bloom' }, + bloom: { default: 0.3, min: 0, max: 1, label: 'Bloom' }, scanlineStr: { default: 1.0, min: 0, max: 1, label: 'Scanlines' }, pixelation: { default: 2.5, min: 1, max: 6, label: 'Pixelation' }, }, diff --git a/src/ui/effects.js b/src/ui/effects.js index d215f3500..87eda3212 100644 --- a/src/ui/effects.js +++ b/src/ui/effects.js @@ -1,2 +1,7 @@ export { VisualEffects } from './visualEffects.js'; -export { STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js'; +export { + STYLES, + GLOBAL_POST_DEFAULTS, + STYLE_PRESET_DEFAULTS, + MILITARY_DETECTION_PRESET, +} from './visualPresets.js'; diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index b0b54ba0b..6533bb748 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -1,6 +1,14 @@ import { PostProcessStage } from 'cesium'; -import { bloomStrengthFromIntensity, clampBloomIntensity, BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; -import { STYLES, SHARPEN_SHADER, TRANSITION_DURATION_MS } from './visualPresets.js'; +import { + bloomStrengthFromIntensity, + clampBloomIntensity, + BLOOM_INTENSITY_DEFAULT, +} from '../bloom.js'; +import { + STYLES, + SHARPEN_SHADER, + TRANSITION_DURATION_MS, +} from './visualPresets.js'; /** Own the post-process stages and their animation, without DOM dependencies. */ export class VisualEffects { @@ -8,10 +16,15 @@ export class VisualEffects { * @param {object} options - Viewer, render ownership callbacks and optional clocks. * Construction is inert; initStyles/initPostProcess retain startup ordering. */ - constructor({ viewer, requestRender, holdRender, releaseRender, + constructor({ + viewer, + requestRender, + holdRender, + releaseRender, requestFrame = (callback) => requestAnimationFrame(callback), cancelFrame = (id) => cancelAnimationFrame(id), - now = () => performance.now(), wallNow = () => Date.now(), + now = () => performance.now(), + wallNow = () => Date.now(), createStage = (options) => new PostProcessStage(options), }) { this.viewer = viewer; @@ -43,9 +56,15 @@ export class VisualEffects { if (this.stopped || this.stageEntries.length) return; for (const [name, shader] of Object.entries(STYLES)) { const uniforms = { intensity: 0.0 }; - if (shader.fragmentShader.includes('uniform float time')) uniforms.time = 0.0; - for (const [name, meta] of Object.entries(shader.uniforms || {})) uniforms[name] = meta.default; - const stage = this.createStage({ name: `godsEyeView_${name}`, fragmentShader: shader.fragmentShader, uniforms }); + if (shader.fragmentShader.includes('uniform float time')) + uniforms.time = 0.0; + for (const [name, meta] of Object.entries(shader.uniforms || {})) + uniforms[name] = meta.default; + const stage = this.createStage({ + name: `godsEyeView_${name}`, + fragmentShader: shader.fragmentShader, + uniforms, + }); stage.enabled = false; this.viewer.scene.postProcessStages.add(stage); this.stages[name] = stage; @@ -56,11 +75,34 @@ export class VisualEffects { initPostProcess(sharpenIntensity = this.sharpenIntensity) { if (this.stopped || this.sharpenStage) return; this.bloomStage = this.viewer.scene.postProcessStages.bloom; - const names = ['glowOnly', 'contrast', 'brightness', 'delta', 'sigma', 'stepSize']; - this.previousBloom = { enabled: this.bloomStage.enabled, uniforms: Object.fromEntries(names.map((name) => [name, this.bloomStage.uniforms[name]])) }; + const names = [ + 'glowOnly', + 'contrast', + 'brightness', + 'delta', + 'sigma', + 'stepSize', + ]; + this.previousBloom = { + enabled: this.bloomStage.enabled, + uniforms: Object.fromEntries( + names.map((name) => [name, this.bloomStage.uniforms[name]]), + ), + }; this.bloomStage.enabled = false; - Object.assign(this.bloomStage.uniforms, { glowOnly: false, contrast: 256.0, brightness: -0.35, delta: 0.25, sigma: 0.35, stepSize: 1.0 }); - this.sharpenStage = this.createStage({ name: 'godsEyeView_sharpen', fragmentShader: SHARPEN_SHADER, uniforms: { amount: 1.3 } }); + Object.assign(this.bloomStage.uniforms, { + glowOnly: false, + contrast: 256.0, + brightness: -0.35, + delta: 0.25, + sigma: 0.35, + stepSize: 1.0, + }); + this.sharpenStage = this.createStage({ + name: 'godsEyeView_sharpen', + fragmentShader: SHARPEN_SHADER, + uniforms: { amount: 1.3 }, + }); this.sharpenStage.enabled = false; this.viewer.scene.postProcessStages.add(this.sharpenStage); this.applySharpenIntensity(sharpenIntensity); @@ -70,17 +112,21 @@ export class VisualEffects { if (this.stopped || !stage) return; stage.uniforms.intensity = value; stage.enabled = value > 0.001; - if (stage.enabled && stage.uniforms.time !== undefined) this.startAnimationLoop(); + if (stage.enabled && stage.uniforms.time !== undefined) + this.startAnimationLoop(); this.requestRender('style-stage'); } syncStagesEnabledFromIntensity() { - for (const [, stage] of this.stageEntries) this.setStageIntensity(stage, stage.uniforms.intensity); + for (const [, stage] of this.stageEntries) + this.setStageIntensity(stage, stage.uniforms.intensity); } syncBloomEnabled() { if (this.stopped || !this.bloomStage) return; - this.bloomStage.enabled = this.bloomEnabled && bloomStrengthFromIntensity(this.bloomIntensity) > 0.06; + this.bloomStage.enabled = + this.bloomEnabled && + bloomStrengthFromIntensity(this.bloomIntensity) > 0.06; } applyBloomIntensity(intensity) { @@ -89,7 +135,7 @@ export class VisualEffects { this.requestRender('bloom'); if (!this.bloomStage) return; const rawStrength = bloomStrengthFromIntensity(this.bloomIntensity); - const strength = rawStrength <= 0.06 ? 0.0 : ((rawStrength - 0.06) / 0.94); + const strength = rawStrength <= 0.06 ? 0.0 : (rawStrength - 0.06) / 0.94; const eased = strength * strength * (3.0 - 2.0 * strength); this.bloomStage.uniforms.contrast = 255.0 - eased * 168.0; this.bloomStage.uniforms.brightness = -0.5 + eased * 0.36; @@ -109,7 +155,8 @@ export class VisualEffects { applySharpenIntensity(value) { if (this.stopped) return; this.sharpenIntensity = value; - if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; + if (this.sharpenStage) + this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; this.requestRender('sharpen'); } @@ -133,9 +180,15 @@ export class VisualEffects { const now = this.now(); const elapsedSec = (this.wallNow() - this.startTime) / 1000.0; for (const [name, transition] of this.transitions) { - const t = Math.min((now - transition.start) / TRANSITION_DURATION_MS, 1.0); + const t = Math.min( + (now - transition.start) / TRANSITION_DURATION_MS, + 1.0, + ); const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; - this.setStageIntensity(this.stages[name], transition.from + (transition.to - transition.from) * eased); + this.setStageIntensity( + this.stages[name], + transition.from + (transition.to - transition.from) * eased, + ); if (t >= 1.0) { this.setStageIntensity(this.stages[name], transition.to); this.transitions.delete(name); diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index f28288e52..76615d0e8 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { VisualEffects } from './visualEffects.js'; -import { GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './visualPresets.js'; +import { + GLOBAL_POST_DEFAULTS, + STYLE_PRESET_DEFAULTS, + MILITARY_DETECTION_PRESET, +} from './visualPresets.js'; function fixture() { let time = 0; @@ -10,15 +14,40 @@ function fixture() { const stages = new Set(); const holds = new Set(); const requests = []; - const bloom = { enabled: true, uniforms: { glowOnly: true, contrast: 3, brightness: 4, delta: 5, sigma: 6, stepSize: 7 } }; + const bloom = { + enabled: true, + uniforms: { + glowOnly: true, + contrast: 3, + brightness: 4, + delta: 5, + sigma: 6, + stepSize: 7, + }, + }; const originalBloom = structuredClone(bloom); - const pipeline = { bloom, add(stage) { stages.add(stage); }, remove(stage) { return stages.delete(stage); } }; + const pipeline = { + bloom, + add(stage) { + stages.add(stage); + }, + remove(stage) { + return stages.delete(stage); + }, + }; const effects = new VisualEffects({ viewer: { scene: { postProcessStages: pipeline } }, requestRender: (reason) => requests.push(reason), - holdRender: (reason) => holds.add(reason), releaseRender: (reason) => holds.delete(reason), - requestFrame: (callback) => { const id = next++; frames.set(id, callback); return id; }, - cancelFrame: (id) => frames.delete(id), now: () => time, wallNow: () => 10_000 + time, + holdRender: (reason) => holds.add(reason), + releaseRender: (reason) => holds.delete(reason), + requestFrame: (callback) => { + const id = next++; + frames.set(id, callback); + return id; + }, + cancelFrame: (id) => frames.delete(id), + now: () => time, + wallNow: () => 10_000 + time, createStage: (options) => ({ ...options, enabled: true }), }); function tick(value) { @@ -27,7 +56,16 @@ function fixture() { frames.clear(); for (const callback of pending) callback(); } - return { effects, frames, stages, holds, requests, bloom, originalBloom, tick }; + return { + effects, + frames, + stages, + holds, + requests, + bloom, + originalBloom, + tick, + }; } test('construction is inert and initialization creates one owned pipeline', () => { @@ -37,7 +75,11 @@ test('construction is inert and initialization creates one owned pipeline', () = f.effects.initStyles(); f.effects.initPostProcess(); assert.equal(f.stages.size, 7); - assert.ok(Object.values(f.effects.stages).every((stage) => !stage.enabled && stage.uniforms.intensity === 0)); + assert.ok( + Object.values(f.effects.stages).every( + (stage) => !stage.enabled && stage.uniforms.intensity === 0, + ), + ); f.effects.initStyles(); f.effects.initPostProcess(); assert.equal(f.stages.size, 7); @@ -50,7 +92,11 @@ test('crossfade uses the established 500ms easing and settles without idle frame f.effects.startTransition('noir', 0, 1); assert.equal(f.effects.frameId, 0, 'zero is a valid pending animation id'); f.effects.startAnimationLoop(); - assert.equal(f.frames.size, 1, 'starting twice cannot schedule duplicate frames'); + assert.equal( + f.frames.size, + 1, + 'starting twice cannot schedule duplicate frames', + ); f.tick(125); assert.equal(f.effects.stages.noir.uniforms.intensity, 0.125); f.tick(250); @@ -69,7 +115,11 @@ test('a superseding transition starts from the current rendered intensity', () = f.effects.initStyles(); f.effects.startTransition('noir', 0, 1); f.tick(250); - f.effects.startTransition('noir', f.effects.stages.noir.uniforms.intensity, 0); + f.effects.startTransition( + 'noir', + f.effects.stages.noir.uniforms.intensity, + 0, + ); f.tick(500); assert.equal(f.effects.stages.noir.uniforms.intensity, 0.25); f.tick(750); @@ -147,7 +197,11 @@ test('stop revokes pending work immediately but retains stages until final destr const staleFrame = [...f.frames.values()][0]; f.effects.stop(); assert.equal(f.frames.size, 0); - assert.equal(f.stages.size, 7, 'Context and Cockpit may still be releasing these stages'); + assert.equal( + f.stages.size, + 7, + 'Context and Cockpit may still be releasing these stages', + ); const intensity = f.effects.stages.retro.uniforms.intensity; staleFrame(); f.effects.startTransition('retro', 0, 1); @@ -156,14 +210,21 @@ test('stop revokes pending work immediately but retains stages until final destr assert.equal(f.effects.stages.retro.uniforms.intensity, intensity); f.effects.destroy(); assert.equal(f.stages.size, 0); - assert.deepEqual(f.bloom, f.originalBloom, 'borrowed bloom configuration is restored'); + assert.deepEqual( + f.bloom, + f.originalBloom, + 'borrowed bloom configuration is restored', + ); f.effects.destroy(); }); test('destroying one instance does not remove another pipeline or clock', () => { const a = fixture(); const b = fixture(); - for (const f of [a, b]) { f.effects.initStyles(); f.effects.startTransition('retro', 0, 1); } + for (const f of [a, b]) { + f.effects.initStyles(); + f.effects.startTransition('retro', 0, 1); + } a.effects.destroy(); b.tick(250); assert.equal(a.stages.size, 0); @@ -179,7 +240,10 @@ test('existing baseline and military presets keep one detection default', () => assert.equal(GLOBAL_POST_DEFAULTS.detectionFadePct, 7); assert.equal(GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct, 1); for (const name of ['retro', 'surveillance', 'thermal']) { - assert.equal(STYLE_PRESET_DEFAULTS[name].detection, MILITARY_DETECTION_PRESET); + assert.equal( + STYLE_PRESET_DEFAULTS[name].detection, + MILITARY_DETECTION_PRESET, + ); } assert.equal(STYLE_PRESET_DEFAULTS.normal, undefined); }); diff --git a/src/ui/visualPresets.js b/src/ui/visualPresets.js index a1415efcc..197605faa 100644 --- a/src/ui/visualPresets.js +++ b/src/ui/visualPresets.js @@ -9,7 +9,14 @@ import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; /** Duration (ms) for shader intensity crossfade between style presets. */ export const TRANSITION_DURATION_MS = 500; /** Map of style name to its GLSL shader module for post-process stages. */ -export const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader }; +export const STYLES = { + retro: retroShader, + surveillance: nightVisionShader, + thermal: thermalShader, + anime: animeShader, + noir: noirShader, + snow: snowShader, +}; /** * The tactical detection look: Dense at 75%. @@ -28,7 +35,10 @@ export const STYLES = { retro: retroShader, surveillance: nightVisionShader, the * SPARSE. Declared ahead of GLOBAL_POST_DEFAULTS because that baseline now reads * from it. */ -export const MILITARY_DETECTION_PRESET = Object.freeze({ mode: 'dense', densityPct: 75 }); +export const MILITARY_DETECTION_PRESET = Object.freeze({ + mode: 'dense', + densityPct: 75, +}); /** Baseline post-processing settings applied on first load (before share-link restore). */ export const GLOBAL_POST_DEFAULTS = { From 12f7e78d83b68113eca5eaf86557056773755163 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 21:56:03 -0700 Subject: [PATCH 03/92] style: format the visual effects package boundary --- scripts/package-boundaries.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 946066474..a9d0ac286 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -302,7 +302,18 @@ }, "visual-effects": { "exports": ["./ui/effects", "./ui/effects/bloom"], - "modules": ["src/ui/effects.js", "src/ui/visualEffects.js", "src/ui/visualPresets.js", "src/bloom.js", "src/styles/retro.js", "src/styles/surveillance.js", "src/styles/thermal.js", "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js"], + "modules": [ + "src/ui/effects.js", + "src/ui/visualEffects.js", + "src/ui/visualPresets.js", + "src/bloom.js", + "src/styles/retro.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/snow.js" + ], "external": ["cesium"] } } From 72d531b18f2d5dbff71e99e870b57a000187afc3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:25:04 -0700 Subject: [PATCH 04/92] refactor: isolate Map Source controls and selection lifecycle --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 9 ++ docs/CURRENT-STATE.md | 9 ++ package.json | 3 +- scripts/format-scope.json | 7 +- scripts/package-boundaries.json | 5 + scripts/qa-map-source-controls.mjs | 75 ++++++++++++ src/mapStackChips.js | 11 +- src/mapStackChips.test.mjs | 36 +++--- src/sharelink.celestial.test.mjs | 1 + src/ui.js | 63 +++------- src/ui/mapSource.js | 2 + src/ui/mapSourceControls.js | 75 ++++++++++++ src/ui/mapSourceControls.test.mjs | 179 +++++++++++++++++++++++++++++ 14 files changed, 405 insertions(+), 72 deletions(-) create mode 100644 scripts/qa-map-source-controls.mjs create mode 100644 src/ui/mapSource.js create mode 100644 src/ui/mapSourceControls.js create mode 100644 src/ui/mapSourceControls.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5855ec39c..14f7fd9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback. + - Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown. - Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index db1e450bc..5ade016ed 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -260,3 +260,12 @@ releasing UI consumers, then destroy to remove owned stages and restore the borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers without loading the renderer. UI presentation and product-action coordination remain in their callers. + +## Map Source controls + +`ui/maps` owns source-chip presentation, selection feedback and its subscription +lifetime. It receives the existing controller and explicit state/action callbacks; +it imports no renderer or application. Source construction and availability policy +remain with the map controller. Rebuilding controls removes their previous chip +listeners, and destruction suppresses late completions without owning or destroying +the supplied controller. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2461def9e..f01f25a4f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +## Map Source control ownership + +Map Source controls own chip listeners, source-state subscriptions and selection +feedback. Loading remains with the supplied map controller. The active chip +follows the source actually displayed, including fallback; obsolete completions +cannot overwrite a newer selection. Refresh and destruction revoke old listeners +and destruction suppresses late UI updates. Available choices and setup behavior +remain unchanged. + ## Visual effects ownership VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom diff --git a/package.json b/package.json index d6ecae4fb..3d2a7bbad 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "./ui/input": "./src/ui/visualInput.js", "./ui/display": "./src/ui/displayControls.js", "./ui/effects": "./src/ui/effects.js", - "./ui/effects/bloom": "./src/bloom.js" + "./ui/effects/bloom": "./src/bloom.js", + "./ui/maps": "./src/ui/mapSource.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3409bdecf..44f6e3ab0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -148,5 +148,10 @@ "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js", - "scripts/qa-visual-effects.mjs" + "scripts/qa-visual-effects.mjs", + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js", + "src/ui/mapSourceControls.test.mjs", + "src/mapStackChips.js", + "scripts/qa-map-source-controls.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a9d0ac286..30366f8b9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -315,5 +315,10 @@ "src/styles/snow.js" ], "external": ["cesium"] + }, + "map-source-controls": { + "exports": ["./ui/maps"], + "modules": ["src/ui/mapSource.js", "src/ui/mapSourceControls.js", "src/mapStackChips.js", "src/keySetupCore.mjs"], + "external": [] } } diff --git a/scripts/qa-map-source-controls.mjs b/scripts/qa-map-source-controls.mjs new file mode 100644 index 000000000..501475566 --- /dev/null +++ b/scripts/qa-map-source-controls.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** Browser acceptance for Map Source control lifecycle and truthful selection. */ +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, ok) => { console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); if (!ok) failures++; }; +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(() => { + const cover = document.getElementById('loading-screen'); + return window.__godsEyeView?.styleManager?._mapSourceControls && cover?.classList.contains('hidden') && Number(getComputedStyle(cover).opacity) === 0; + }, { timeout: 60_000 }); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const controller = manager.mapStackController; + const original = controller.setStack; + window.__qaMapSelections = []; + controller.setStack = function(id) { window.__qaMapSelections.push(id); return original.call(this, id); }; + manager.setPanelCollapsed('control-panel', false, { persist: false, syncShare: false }); + }); + await page.click('[data-stack-id="osm"]'); + await page.waitForFunction(() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'osm'); + check('a native chip click requests OSM exactly once', await page.evaluate(() => window.__qaMapSelections.length === 1 && window.__qaMapSelections[0] === 'osm')); + check('active chip and status follow the displayed OSM source', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + return manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' && manager._mapStackStatus.textContent === manager.mapStackController.getState().activeStack.shortLabel; + })); + check('the public map action uses the same component and truthful result', await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const result = await manager.setMapStack('esri-imagery'); + const active = manager.mapStackController.getActiveId(); + return window.__qaMapSelections.at(-1) === 'esri-imagery' && manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset.stackId === active && (result.ok || !!manager.mapStackController.getState().lastError); + })); + check('refresh revokes retained old chips', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]'); + manager._mapSourceControls.refresh(); + const before = window.__qaMapSelections.length; + old.click(); + return window.__qaMapSelections.length === before; + })); + check('refreshed chips still select once', await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const before = window.__qaMapSelections.length; + manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); + await Promise.resolve(); + return window.__qaMapSelections.length === before + 1; + })); + await page.waitForFunction(() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'osm'); + fs.mkdirSync('qa-shots/map-source-controls', { recursive: true }); + await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' }); + await page.setViewport({ width: 620, height: 900 }); + await page.waitForFunction(() => document.getElementById('left-panel-stack')?.dataset.layoutMode === 'mobile'); + await page.screenshot({ path: 'qa-shots/map-source-controls/narrow.png' }); + check('destroyed controls cannot issue requests from retained chips', await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const before = window.__qaMapSelections.length; + manager._mapSourceControls.destroy(); + manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); + const result = await manager._mapSourceControls.select('esri-imagery'); + return result === null && window.__qaMapSelections.length === before; + })); + check('no uncaught browser errors', errors.length === 0); + if (errors.length) console.log(JSON.stringify(errors)); +} finally { await browser.close(); } +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/src/mapStackChips.js b/src/mapStackChips.js index c87e319b3..0717904e0 100644 --- a/src/mapStackChips.js +++ b/src/mapStackChips.js @@ -1,8 +1,8 @@ // MAP STACK source chips — the always-visible replacement for the ` dropdown's type-ahead or while typing in a field (M9). - const isFormControl = e.target?.matches?.('select, input, textarea') - || e.target === this._locationSearch; - if (isFormControl) return; - - const keyIndex = QWERTY_KEYS.indexOf(e.key.toUpperCase()); - if (keyIndex === -1) return; - - const city = CITY_POIS[this._expandedCityId]; - if (city && keyIndex < city.pois.length) { - this._onPoiClick(this._expandedCityId, keyIndex); - } - }; - document.addEventListener('keydown', this._poiKeydownHandler); - - // Search toggle (expand/collapse) - this._searchToggle.addEventListener('click', () => { - this._locationSearch.classList.toggle('expanded'); - if (this._locationSearch.classList.contains('expanded')) { - this._locationSearch.focus(); - } + this._locationControls?.destroy(); + this._locationLookup?.destroy(); + this._locationLookup = new LocationSearch({ + input: this._locationSearch, + begin: () => this._beginDeferredNavigation('location'), + isCurrent: (generation) => !this._disposed && generation === this._navigationGeneration, + beforeFly: (generation) => this._reassertNavigationHandoff(generation), + search: (query, options) => searchAndFlyTo(this.viewer, query, { + placeSearch: this.placeSearch, + ...options, + }), + onStart: (generation) => { this._activeLocationSearchGeneration = generation; }, + onResult: (destination, query) => { + this._searchedLocationLabel = destination.label || query; + this._setActiveLocation(null); + this._currentPoi = null; + this._collapsePOIRow(); + this._updateLocationMiniStatus(); + }, + onMissing: () => this._showToast('Location not found'), + onError: (error) => { + console.error('[Search] Geocoding failed:', error); + this._showToast('Search failed'); + }, + onSettled: (generation) => this._settleLocationSearchUi(generation), }); - - // Search submit on Enter - this._locationSearch.addEventListener('keydown', async (e) => { - if (e.key === 'Enter') { - const query = this._locationSearch.value.trim(); - if (!query) return; - const generation = this._beginDeferredNavigation('location'); - if (generation === false) { - this._locationSearch.classList.remove('searching'); - this._locationSearch.blur(); - return; - } - this._activeLocationSearchGeneration = generation; - this._locationSearchController?.abort(); - const searchController = new AbortController(); - this._locationSearchController = searchController; - this._locationSearch.classList.add('searching'); - try { - const destination = await searchAndFlyTo(this.viewer, query, { - placeSearch: this.placeSearch, - signal: searchController.signal, - beforeFly: () => this._reassertNavigationHandoff(generation), - }); - if (this._disposed || generation !== this._navigationGeneration) return; - if (destination?.cancelled) { - // Authority changed while the lookup was resolving; remain inert. - } else if (destination) { - // The ACTIVE STYLE indicator reports the STYLE and nothing else. - // Writing the searched city here made the top-right corner read - // "ACTIVE STYLE / TOKYO"; where the camera is belongs to the - // LOCATION panel's own readout, which is updated below. - // - // Set before _setActiveLocation(null) so its own mini-status - // refresh already sees the destination — the readout never blinks - // through "Location: --" on the way to the searched place. - this._searchedLocationLabel = destination.label || query; - this._setActiveLocation(null); - this._currentPoi = null; - this._collapsePOIRow(); - this._updateLocationMiniStatus(); - } else { - this._showToast('Location not found'); - } - } catch (err) { - if (searchController.signal.aborted) return; - console.error('[Search] Geocoding failed:', err); - if (this._disposed || generation !== this._navigationGeneration) return; - this._showToast('Search failed'); - } finally { - if (this._locationSearchController === searchController) this._locationSearchController = null; - this._settleLocationSearchUi(generation); - } - } + this._locationControls = new LocationControls({ + elements: { + pills: this._locationPills, poiRow: this._poiRow, divider: this._locationBarDivider, + search: this._locationSearch, searchToggle: this._searchToggle, + resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn], + statusCity: this._locationMiniCity, statusPoi: this._locationMiniPoi, + }, + cities: CITY_POIS, + getExpandedCity: () => this._expandedCityId, + onCity: (id) => this._onCityPillClick(id), + onPoi: (id, index) => this._onPoiClick(id, index), + onSearch: (query) => this._locationLookup.run(query), + onReset: () => this.resetToGlobeView(), }); } @@ -8745,28 +8691,9 @@ export class StyleManager { * @returns {void} */ _expandPOIRow(cityId) { - const QWERTY_KEYS = ['Q', 'W', 'E', 'R', 'T']; - const city = CITY_POIS[cityId]; - if (!city) return; - + if (!CITY_POIS[cityId]) return; this._expandedCityId = cityId; - - // Build POI pill buttons - this._poiRow.innerHTML = ''; - city.pois.forEach((poi, idx) => { - const pill = document.createElement('button'); - pill.className = 'poi-pill'; - pill.dataset.poiIndex = idx; - pill.innerHTML = `${QWERTY_KEYS[idx] || idx + 1}${poi.name}`; - pill.addEventListener('click', () => this._onPoiClick(cityId, idx)); - this._poiRow.appendChild(pill); - }); - - // Animate expansion - requestAnimationFrame(() => { - this._poiRow.classList.add('expanded'); - this._locationBarDivider.classList.add('visible'); - }); + this._locationControls.showPois(cityId); } /** @@ -8776,8 +8703,7 @@ export class StyleManager { _collapsePOIRow() { this._expandedCityId = null; this._activePoiIndex = null; - this._poiRow.classList.remove('expanded'); - this._locationBarDivider.classList.remove('visible'); + this._locationControls.hidePois(); } /** @@ -8785,9 +8711,7 @@ export class StyleManager { * @returns {void} */ _updatePoiHighlight() { - this._poiRow.querySelectorAll('.poi-pill').forEach(pill => { - pill.classList.toggle('active', parseInt(pill.dataset.poiIndex) === this._activePoiIndex); - }); + this._locationControls.highlightPoi(this._activePoiIndex); } /** @@ -8813,9 +8737,7 @@ export class StyleManager { // free-text destination has been superseded. Clearing only on a real id // leaves the search path's own _setActiveLocation(null) untouched. if (locationId) this._searchedLocationLabel = null; - this._locationPills.querySelectorAll('.location-pill').forEach(pill => { - pill.classList.toggle('active', pill.dataset.locationId === locationId); - }); + this._locationControls?.highlightCity(locationId); this._updateLocationMiniStatus(); } @@ -8825,14 +8747,11 @@ export class StyleManager { * @returns {void} */ _updateLocationMiniStatus() { - if (!this._locationMiniCity || !this._locationMiniPoi) return; - const lines = locationMiniStatus({ + this._locationControls?.renderStatus({ city: this._activeLocationId ? CITY_POIS[this._activeLocationId] : null, currentPoi: this._currentPoi, searchedLabel: this._searchedLocationLabel, }); - this._locationMiniCity.textContent = lines.city; - this._locationMiniPoi.textContent = lines.poi; } /** @@ -8852,11 +8771,7 @@ export class StyleManager { * @returns {void} */ _initOrbit() { - // Create orbit indicator element - this._orbitIndicator = document.createElement('div'); - this._orbitIndicator.id = 'orbit-indicator'; - this._orbitIndicator.innerHTML = '↻ ORBIT'; - document.body.appendChild(this._orbitIndicator); + this._orbitIndicator = this._locationControls.createOrbitIndicator(); } /** @@ -8889,14 +8804,6 @@ export class StyleManager { } } - /** Wire the persistent reset control to the same route used by voice. */ - _initResetGlobeButton() { - this._globeResetHandler = () => { this.resetToGlobeView(); }; - for (const button of [this._resetGlobeBtn, this._cockpitResetGlobeBtn]) { - button?.addEventListener('click', this._globeResetHandler); - } - } - /** Wire the top-center action that clears only manager-owned data layers. */ _initClearSelectedLayersButton() { if (!this._clearSelectedLayersBtn) return; @@ -9374,13 +9281,14 @@ export class StyleManager { this._displayControls?.destroy(); this._mapSourceControls?.destroy(); this._clearLayersControl?.destroy(); + this._locationControls?.destroy(); this._visualEffects.stop(); this._styleParameters?.destroy(); for (const control of this._panelDisclosureControls || []) control.destroy(); this._panelDisclosureControls = []; this._hoverPanelControls?.forEach((control) => control.destroy()); this._hoverPanelControls?.clear(); - this._locationSearchController?.abort(); + this._locationLookup?.destroy(); this._cancelMapSourceFocus?.(); // Revoke persistence/hash authority before teardown can emit manager changes. this._layerStateCoordinator?.destroy(); @@ -9458,31 +9366,13 @@ export class StyleManager { this._dataManagerVisibilityRequestUnsubscribe = null; this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; - if (this._globeResetHandler) { - this._resetGlobeBtn?.removeEventListener('click', this._globeResetHandler); - this._cockpitResetGlobeBtn?.removeEventListener('click', this._globeResetHandler); - this._globeResetHandler = null; - } - this._cctvUnsubscribe?.(); - this._cctvUnsubscribe = null; - this._commandDockTrayObserver?.disconnect?.(); - this._commandDockTrayObserver = null; - this._draggableResizeObserver?.disconnect(); - this._draggableResizeObserver = null; - if (this._windowResizeHandler) { - window.removeEventListener('resize', this._windowResizeHandler); - this._windowResizeHandler = null; - } if (this._loadingVisibilityHandler) { document.removeEventListener('visibilitychange', this._loadingVisibilityHandler); this._loadingVisibilityHandler = null; } this._stopLoadingFeedbackTicker(); - if (this._poiKeydownHandler) { - document.removeEventListener('keydown', this._poiKeydownHandler); - this._poiKeydownHandler = null; - } + // Stop the independently owned traffic and loading feedback tickers. if (this._trafficChipTicker) { diff --git a/src/ui/location.js b/src/ui/location.js new file mode 100644 index 000000000..5c593d2e0 --- /dev/null +++ b/src/ui/location.js @@ -0,0 +1,3 @@ +export { LocationControls } from './locationControls.js'; +export { LocationSearch } from './locationSearch.js'; +export { locationMiniStatus } from '../locationStatus.js'; diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js new file mode 100644 index 000000000..21f905816 --- /dev/null +++ b/src/ui/locationControls.js @@ -0,0 +1,119 @@ +import { locationMiniStatus } from '../locationStatus.js'; +const POI_KEYS = ['Q', 'W', 'E', 'R', 'T']; + +/** Location DOM, keyboard handling and pending row animation over supplied actions. */ +export class LocationControls { + constructor({ elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc = document, requestFrame = requestAnimationFrame, cancelFrame = cancelAnimationFrame }) { + Object.assign(this, { elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc, requestFrame, cancelFrame }); + this.removers = []; + this.poiRemovers = []; + this.frame = null; + this.destroyed = false; + this.rowGeneration = 0; + elements.pills.replaceChildren(); + for (const [id, city] of Object.entries(cities)) { + const pill = doc.createElement('button'); + pill.type = 'button'; + pill.className = 'location-pill'; + pill.dataset.locationId = id; + pill.textContent = city.name; + this.bind(pill, 'click', () => onCity(id)); + elements.pills.appendChild(pill); + } + this.bind(doc, 'keydown', event => { + const cityId = getExpandedCity(); + if (!cityId || event.target?.matches?.('select, input, textarea') || event.target === elements.search) return; + const index = POI_KEYS.indexOf(event.key.toUpperCase()); + if (index !== -1 && index < (cities[cityId]?.pois.length || 0)) onPoi(cityId, index); + }); + this.bind(elements.searchToggle, 'click', () => { + elements.search.classList.toggle('expanded'); + if (elements.search.classList.contains('expanded')) elements.search.focus(); + }); + this.bind(elements.search, 'keydown', event => { + if (event.key === 'Enter') void onSearch(elements.search.value); + }); + for (const button of elements.resetButtons) this.bind(button, 'click', onReset); + } + bind(element, event, handler, removers = this.removers) { + if (!element) return; + const listener = (...args) => { if (!this.destroyed) return handler(...args); }; + element.addEventListener(event, listener); + removers.push(() => element.removeEventListener(event, listener)); + } + cancelExpansion() { + this.rowGeneration++; + if (this.frame !== null) this.cancelFrame(this.frame); + this.frame = null; + } + showPois(cityId) { + if (this.destroyed) return; + const city = this.cities[cityId]; + if (!city) return; + this.cancelExpansion(); + const generation = this.rowGeneration; + for (const remove of this.poiRemovers.splice(0)) remove(); + this.elements.poiRow.replaceChildren(); + city.pois.forEach((poi, index) => { + const pill = this.doc.createElement('button'); + pill.type = 'button'; + pill.className = 'poi-pill'; + pill.dataset.poiIndex = index; + const key = this.doc.createElement('span'); + key.className = 'poi-pill-key'; + key.textContent = POI_KEYS[index] || index + 1; + const label = this.doc.createElement('span'); + label.className = 'poi-pill-name'; + label.textContent = poi.name; + pill.append(key, label); + this.bind(pill, 'click', () => this.onPoi(cityId, index), this.poiRemovers); + this.elements.poiRow.appendChild(pill); + }); + this.frame = this.requestFrame(() => { + if (this.destroyed || generation !== this.rowGeneration) return; + this.frame = null; + this.elements.poiRow.classList.add('expanded'); + this.elements.divider.classList.add('visible'); + }); + } + hidePois() { + if (this.destroyed) return; + this.cancelExpansion(); + this.elements.poiRow.classList.remove('expanded'); + this.elements.divider.classList.remove('visible'); + } + highlightPoi(index) { + if (this.destroyed) return; + for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill')) pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index); + } + highlightCity(id) { + if (this.destroyed) return; + for (const pill of this.elements.pills.querySelectorAll('.location-pill')) pill.classList.toggle('active', pill.dataset.locationId === id); + } + renderStatus(state) { + if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) return; + const lines = locationMiniStatus(state); + this.elements.statusCity.textContent = lines.city; + this.elements.statusPoi.textContent = lines.poi; + } + createOrbitIndicator() { + if (this.destroyed) return null; + if (!this.orbitIndicator) { + this.orbitIndicator = this.doc.createElement('div'); + this.orbitIndicator.id = 'orbit-indicator'; + const icon = this.doc.createElement('span'); + icon.className = 'orbit-icon'; + icon.textContent = '↻'; + this.orbitIndicator.append(icon, ' ORBIT'); + this.doc.body.appendChild(this.orbitIndicator); + } + return this.orbitIndicator; + } + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.cancelExpansion(); + for (const remove of [...this.poiRemovers.splice(0), ...this.removers.splice(0)]) remove(); + this.orbitIndicator?.remove(); + } +} diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs new file mode 100644 index 000000000..86c2465a3 --- /dev/null +++ b/src/ui/locationControls.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { LocationControls } from './locationControls.js'; + +function node() { + const classes = new Set(); + return { + children: [], dataset: {}, listeners: new Map(), className: '', textContent: '', + classList: { add: (...names) => names.forEach(name => classes.add(name)), remove: (...names) => names.forEach(name => classes.delete(name)), contains: name => classes.has(name), toggle(name, enabled = !classes.has(name)) { if(enabled) classes.add(name); else classes.delete(name); } }, + addEventListener(name, callback) { if(!this.listeners.has(name)) this.listeners.set(name,new Set()); this.listeners.get(name).add(callback); }, + removeEventListener(name, callback) { this.listeners.get(name)?.delete(callback); }, + fire(name,event={}) { for(const callback of this.listeners.get(name)||[]) callback(event); }, + appendChild(child) { this.children.push(child); child.parent=this; }, + append(...children) { for(const child of children) if(typeof child!=='string') this.appendChild(child); }, + replaceChildren() { this.children=[]; }, + remove() { if(this.parent) this.parent.children=this.parent.children.filter(child=>child!==this); }, + querySelectorAll(selector) { return this.children.flatMap(child=>[...(child.className===selector.slice(1)?[child]:[]),...child.querySelectorAll(selector)]); }, + focus() { this.focused=true; }, + }; +} +function fixture() { + const elements={pills:node(),poiRow:node(),divider:node(),search:node(),searchToggle:node(),resetButtons:[node(),node()],statusCity:node(),statusPoi:node()}; + const doc=node();doc.createElement=node;doc.body=node(); + const cities={a:{name:'City A',pois:[{name:'First'},{name:'Second'}]},b:{name:'City B',pois:[{name:'Elsewhere'}]}}; + const calls=[];const frames=new Map();const cancelled=[];let next=0; + const controls=new LocationControls({elements,cities,getExpandedCity:()=> 'a',onCity:id=>calls.push(['city',id]),onPoi:(id,index)=>calls.push(['poi',id,index]),onSearch:query=>calls.push(['search',query]),onReset:()=>calls.push(['reset']),doc,requestFrame:fn=>{const id=next++;frames.set(id,fn);return id},cancelFrame:id=>cancelled.push(id)}); + return {elements,doc,controls,calls,frames,cancelled}; +} +test('hiding a POI row cancels frame zero and rejects an already queued expansion', () => { + const f=fixture();f.controls.showPois('a');const callback=f.frames.get(0);f.controls.hidePois();callback(); + assert.deepEqual(f.cancelled,[0]);assert.equal(f.elements.poiRow.classList.contains('expanded'),false);assert.equal(f.elements.divider.classList.contains('visible'),false); +}); +test('replacing POIs removes old click actions and presents the final row', () => { + const f=fixture();f.controls.showPois('a');const old=f.elements.poiRow.children[0];f.controls.showPois('b');old.fire('click'); + assert.deepEqual(f.calls,[]);f.elements.poiRow.children[0].fire('click');assert.deepEqual(f.calls,[['poi','b',0]]); + f.frames.get(0)();assert.equal(f.elements.poiRow.classList.contains('expanded'),false);f.frames.get(1)();assert.equal(f.elements.poiRow.classList.contains('expanded'),true); +}); +test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => { + const f=fixture();f.controls.showPois('a');f.controls.createOrbitIndicator();assert.equal(f.doc.body.children.length,1);const city=f.elements.pills.children[0];const poi=f.elements.poiRow.children[0];f.controls.destroy();f.controls.destroy(); + city.fire('click');poi.fire('click');f.elements.search.value='Place';f.elements.search.fire('keydown',{key:'Enter'});f.doc.fire('keydown',{key:'Q'});f.elements.resetButtons[0].fire('click');f.frames.get(0)();assert.deepEqual(f.calls,[]);assert.equal(f.doc.body.children.length,0); +}); +test('location and POI keys route once while form controls retain typing', () => { + const f=fixture();f.doc.fire('keydown',{key:'W',target:{matches:()=>false}});f.doc.fire('keydown',{key:'Q',target:{matches:()=>true}});f.elements.resetButtons[1].fire('click');assert.deepEqual(f.calls,[['poi','a',1],['reset']]); +}); diff --git a/src/ui/locationSearch.js b/src/ui/locationSearch.js new file mode 100644 index 000000000..5f883ec41 --- /dev/null +++ b/src/ui/locationSearch.js @@ -0,0 +1,49 @@ +/** One cancellable place lookup at a time, under the caller's camera authority. */ +export class LocationSearch { + constructor({ input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }) { + Object.assign(this, { input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }); + this.controller = null; + this.generation = 0; + this.destroyed = false; + } + async run(query) { + query = String(query || '').trim(); + if (!query || this.destroyed) return; + const authority = this.begin(); + if (authority === false) { + this.input.classList.remove('searching'); + this.input.blur(); + return; + } + this.controller?.abort(); + const controller = new AbortController(); + this.controller = controller; + const generation = ++this.generation; + const current = () => !this.destroyed && generation === this.generation && this.isCurrent(authority); + this.onStart(authority); + this.input.classList.add('searching'); + try { + const destination = await this.search(query, { + signal: controller.signal, + beforeFly: () => current() && this.beforeFly(authority), + }); + if (!current() || controller.signal.aborted) return; + if (destination?.cancelled) return; + if (destination) this.onResult(destination, query); + else this.onMissing(); + } catch (error) { + if (controller.signal.aborted || !current()) return; + this.onError(error); + } finally { + if (this.controller === controller) this.controller = null; + if (!this.destroyed) this.onSettled(authority); + } + } + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.generation++; + this.controller?.abort(); + this.controller = null; + } +} diff --git a/src/ui/locationSearch.test.mjs b/src/ui/locationSearch.test.mjs new file mode 100644 index 000000000..795d32768 --- /dev/null +++ b/src/ui/locationSearch.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { LocationSearch } from './locationSearch.js'; + +function fixture() { + const calls = []; + const requests = []; + let authority = 0; + const input = { classList: { add: name => calls.push(['add',name]), remove: name => calls.push(['remove',name]) }, blur: () => calls.push(['blur']) }; + const search = new LocationSearch({ + input, + begin: () => ++authority, + isCurrent: ticket => ticket === authority, + beforeFly: ticket => { calls.push(['fly',ticket]); return true; }, + search: (query, options) => new Promise((resolve,reject) => requests.push({query,options,resolve,reject})), + onStart: ticket => calls.push(['start',ticket]), + onResult: (result,query) => calls.push(['result',result,query]), + onMissing: () => calls.push(['missing']), + onError: error => calls.push(['error',error.message]), + onSettled: ticket => calls.push(['settled',ticket]), + }); + return {search,requests,calls,takeCamera:()=>++authority}; +} + +test('a newer lookup aborts its predecessor and only its result is presented', async () => { + const f=fixture();const first=f.search.run(' First ');const second=f.search.run('Second'); + assert.equal(f.requests[0].query,'First');assert.equal(f.requests[0].options.signal.aborted,true); + assert.equal(f.requests[0].options.beforeFly(),false); + assert.equal(f.requests[1].options.beforeFly(),true); + f.requests[1].resolve({label:'Second'});await second; + f.requests[0].resolve({label:'First'});await first; + assert.deepEqual(f.calls.filter(x=>x[0]==='result'),[['result',{label:'Second'},'Second']]); + assert.equal(f.search.controller,null); +}); +test('another camera action revokes a pending lookup without presenting an error', async () => { + const f=fixture();const pending=f.search.run('Place');f.takeCamera(); + assert.equal(f.requests[0].options.beforeFly(),false); + f.requests[0].reject(new Error('old failure'));await pending; + assert.equal(f.calls.some(x=>x[0]==='error'||x[0]==='result'),false); +}); +test('missing and failed current lookups settle the exact owning generation', async () => { + const f=fixture();let pending=f.search.run('Missing');f.requests[0].resolve(null);await pending; + assert.ok(f.calls.some(x=>x[0]==='missing')); + pending=f.search.run('Failure');f.requests[1].reject(new Error('unavailable'));await pending; + assert.deepEqual(f.calls.filter(x=>x[0]==='error'),[['error','unavailable']]); + assert.deepEqual(f.calls.filter(x=>x[0]==='settled'),[['settled',1],['settled',2]]); +}); +test('destruction cancels work and rejects late presentation and future requests', async () => { + const f=fixture();const pending=f.search.run('Place');f.search.destroy();f.search.destroy(); + assert.equal(f.requests[0].options.signal.aborted,true); + f.requests[0].resolve({label:'Place'});await pending;await f.search.run('Later'); + assert.equal(f.requests.length,1);assert.equal(f.calls.some(x=>x[0]==='result'||x[0]==='settled'),false); +}); +test('empty queries and refused navigation do not issue a lookup', async () => { + const f=fixture();await f.search.run(' ');f.search.begin=()=>false;await f.search.run('Place'); + assert.equal(f.requests.length,0);assert.ok(f.calls.some(x=>x[0]==='blur')); +}); From 82d922ae87cfa7764f201da9411f416448f5f8b5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:55:18 -0700 Subject: [PATCH 16/92] style: format Location component boundary --- scripts/package-boundaries.json | 212 +++++++-------------------- scripts/qa-location-controls.mjs | 240 +++++++++++++++++++++++++------ src/locationStatus.js | 5 +- src/ui/locationControls.js | 72 ++++++++-- src/ui/locationControls.test.mjs | 155 ++++++++++++++++---- src/ui/locationSearch.js | 31 +++- src/ui/locationSearch.test.mjs | 117 ++++++++++----- 7 files changed, 557 insertions(+), 275 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 232869182..9804b619e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite" - ], - "modules": [ - "build/vite.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -65,24 +45,16 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -97,19 +69,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -120,19 +86,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -140,50 +100,30 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], - "modules": [ - "server/providers/traffic.js", - "src/data/tomtomTiles.js" - ], + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], - "modules": [ - "server/providers/firms.js", - "src/data/firmsCsv.js" - ], + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -192,19 +132,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -413,14 +314,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -430,10 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -444,9 +338,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index bc761a913..20b0bf861 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -2,53 +2,213 @@ /** Location UI acceptance; controlled search results isolate UI races from geocoder availability. */ 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',e=>errors.push(e.message));let failures=0; -const check=(name,ok)=>{console.log(`[${ok?'PASS':'FAIL'}] ${name}`);if(!ok)failures++}; +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', (e) => errors.push(e.message)); +let failures = 0; +const check = (name, ok) => { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +}; try { - await page.setViewport({width:1440,height:900}); - await page.goto(`${process.env.QA_BASE_URL||'http://localhost:4173'}/?welcome=0`,{waitUntil:'domcontentloaded'}); - await page.waitForFunction(()=>window.__godsEyeView?.styleManager?._locationControls&&document.getElementById('loading-screen')?.classList.contains('hidden'),{timeout:60000}); + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._locationControls && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); await page.click('#location-bar-toggle'); await page.click('[data-pin-target="location-bar"]'); - const cityId=await page.$eval('.location-pill',node=>node.dataset.locationId); + const cityId = await page.$eval( + '.location-pill', + (node) => node.dataset.locationId, + ); await page.click('.location-pill'); - await page.waitForFunction(()=>document.getElementById('poi-row').classList.contains('expanded')); - check('native city selection highlights and expands its POIs',await page.evaluate(id=>window.__godsEyeView.styleManager._activeLocationId===id&&document.querySelector('.location-pill.active')?.dataset.locationId===id,cityId)); + await page.waitForFunction(() => + document.getElementById('poi-row').classList.contains('expanded'), + ); + check( + 'native city selection highlights and expands its POIs', + await page.evaluate( + (id) => + window.__godsEyeView.styleManager._activeLocationId === id && + document.querySelector('.location-pill.active')?.dataset.locationId === + id, + cityId, + ), + ); await page.keyboard.press('w'); - check('POI keyboard action selects the second landmark',await page.evaluate(()=>window.__godsEyeView.styleManager._activePoiIndex===1&&document.querySelector('.poi-pill.active')?.dataset.poiIndex==='1')); - check('orbit controls and indicator agree',await page.evaluate(()=>{const ui=window.__godsEyeView.styleManager;ui.setOrbit(true);const active=ui.orbitController.active&&document.getElementById('orbit-indicator').classList.contains('active');ui.setOrbit(false);return active&&!ui.orbitController.active&&!document.getElementById('orbit-indicator').classList.contains('active')})); + check( + 'POI keyboard action selects the second landmark', + await page.evaluate( + () => + window.__godsEyeView.styleManager._activePoiIndex === 1 && + document.querySelector('.poi-pill.active')?.dataset.poiIndex === '1', + ), + ); + check( + 'orbit controls and indicator agree', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + ui.setOrbit(true); + const active = + ui.orbitController.active && + document.getElementById('orbit-indicator').classList.contains('active'); + ui.setOrbit(false); + return ( + active && + !ui.orbitController.active && + !document.getElementById('orbit-indicator').classList.contains('active') + ); + }), + ); await page.click('#search-toggle'); - await page.type('#location-search','qwerty'); - check('typing in search leaves POI selection unchanged',await page.evaluate(()=>window.__godsEyeView.styleManager._activePoiIndex===1)); - await page.evaluate(()=>{ - const lookup=window.__godsEyeView.styleManager._locationLookup; - window.__qaSearchRequests=[]; - lookup.search=(query,options)=>new Promise(resolve=>window.__qaSearchRequests.push({query,options,resolve})); - document.getElementById('location-search').value='First'; + await page.type('#location-search', 'qwerty'); + check( + 'typing in search leaves POI selection unchanged', + await page.evaluate( + () => window.__godsEyeView.styleManager._activePoiIndex === 1, + ), + ); + await page.evaluate(() => { + const lookup = window.__godsEyeView.styleManager._locationLookup; + window.__qaSearchRequests = []; + lookup.search = (query, options) => + new Promise((resolve) => + window.__qaSearchRequests.push({ query, options, resolve }), + ); + document.getElementById('location-search').value = 'First'; }); await page.keyboard.press('Enter'); - await page.waitForFunction(()=>window.__qaSearchRequests.length===1); - await page.evaluate(()=>{document.getElementById('location-search').value='Second';document.getElementById('location-search').focus()}); + await page.waitForFunction(() => window.__qaSearchRequests.length === 1); + await page.evaluate(() => { + document.getElementById('location-search').value = 'Second'; + document.getElementById('location-search').focus(); + }); await page.keyboard.press('Enter'); - await page.waitForFunction(()=>window.__qaSearchRequests.length===2); - check('second native submit aborts the first request and keeps current busy state',await page.evaluate(()=>window.__qaSearchRequests[0].options.signal.aborted&&document.getElementById('location-search').classList.contains('searching'))); - await page.evaluate(()=>window.__qaSearchRequests[1].resolve({label:'Second landmark, Test city'})); - await page.waitForFunction(()=>window.__godsEyeView.styleManager._searchedLocationLabel==='Second landmark, Test city'); - await page.evaluate(()=>window.__qaSearchRequests[0].resolve({label:'First landmark, Test city'})); - check('late result cannot overwrite the current location readout or style',await page.evaluate(()=>{const ui=window.__godsEyeView.styleManager;return ui._searchedLocationLabel==='Second landmark, Test city'&&ui._locationMiniPoi.textContent.includes('Second landmark')&&ui.activeStyle==='normal'&&!ui._locationSearch.classList.contains('searching')})); - check('cancelled POI expansion stays closed after a frame',await page.evaluate(async id=>{const ui=window.__godsEyeView.styleManager;ui._expandPOIRow(id);ui._collapsePOIRow();await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));return !ui._poiRow.classList.contains('expanded')&&!ui._locationBarDivider.classList.contains('visible')},cityId)); - await page.evaluate(id=>window.__godsEyeView.styleManager._onCityPillClick(id),cityId); - await page.waitForFunction(()=>document.getElementById('poi-row').classList.contains('expanded')); + await page.waitForFunction(() => window.__qaSearchRequests.length === 2); + check( + 'second native submit aborts the first request and keeps current busy state', + await page.evaluate( + () => + window.__qaSearchRequests[0].options.signal.aborted && + document + .getElementById('location-search') + .classList.contains('searching'), + ), + ); + await page.evaluate(() => + window.__qaSearchRequests[1].resolve({ + label: 'Second landmark, Test city', + }), + ); + await page.waitForFunction( + () => + window.__godsEyeView.styleManager._searchedLocationLabel === + 'Second landmark, Test city', + ); + await page.evaluate(() => + window.__qaSearchRequests[0].resolve({ + label: 'First landmark, Test city', + }), + ); + check( + 'late result cannot overwrite the current location readout or style', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + return ( + ui._searchedLocationLabel === 'Second landmark, Test city' && + ui._locationMiniPoi.textContent.includes('Second landmark') && + ui.activeStyle === 'normal' && + !ui._locationSearch.classList.contains('searching') + ); + }), + ); + check( + 'cancelled POI expansion stays closed after a frame', + await page.evaluate(async (id) => { + const ui = window.__godsEyeView.styleManager; + ui._expandPOIRow(id); + ui._collapsePOIRow(); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return ( + !ui._poiRow.classList.contains('expanded') && + !ui._locationBarDivider.classList.contains('visible') + ); + }, cityId), + ); + await page.evaluate( + (id) => window.__godsEyeView.styleManager._onCityPillClick(id), + cityId, + ); + await page.waitForFunction(() => + document.getElementById('poi-row').classList.contains('expanded'), + ); // Finish the camera's existing city flight before inspecting both layouts. - await page.waitForFunction(()=>!window.__godsEyeView.viewer.camera._currentFlight,{timeout:10000}); - fs.mkdirSync('qa-shots/location-controls',{recursive:true}); - await page.screenshot({path:'qa-shots/location-controls/desktop.png'}); - await page.setViewport({width:620,height:900}); - await page.waitForFunction(()=>document.getElementById('left-panel-stack')?.dataset.layoutMode==='mobile'); - await page.screenshot({path:'qa-shots/location-controls/narrow.png'}); - check('native reset returns through the existing camera handoff',await page.evaluate(async()=>{const ui=window.__godsEyeView.styleManager;ui._resetGlobeBtn.click();const pending=ui._globeResetPromise;if(!pending)return false;await pending;return !ui.orbitController.active&&!window.__godsEyeView.viewer.trackedEntity})); - check('destroyed controls cannot navigate or submit another lookup',await page.evaluate(()=>{const ui=window.__godsEyeView.styleManager;const count=window.__qaSearchRequests.length;ui._locationControls.destroy();ui._locationLookup.destroy();const before=ui._navigationGeneration;ui._locationPills.querySelector('button').click();ui._resetGlobeBtn.click();ui._locationSearch.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));return before===ui._navigationGeneration&&count===window.__qaSearchRequests.length})); - check('no uncaught browser errors',errors.length===0); -}finally{await browser.close()} -console.log(`RESULT: ${failures} failures`);process.exitCode=failures?1:0; + await page.waitForFunction( + () => !window.__godsEyeView.viewer.camera._currentFlight, + { timeout: 10000 }, + ); + fs.mkdirSync('qa-shots/location-controls', { recursive: true }); + await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' }); + await page.setViewport({ width: 620, height: 900 }); + await page.waitForFunction( + () => + document.getElementById('left-panel-stack')?.dataset.layoutMode === + 'mobile', + ); + await page.screenshot({ path: 'qa-shots/location-controls/narrow.png' }); + check( + 'native reset returns through the existing camera handoff', + await page.evaluate(async () => { + const ui = window.__godsEyeView.styleManager; + ui._resetGlobeBtn.click(); + const pending = ui._globeResetPromise; + if (!pending) return false; + await pending; + return ( + !ui.orbitController.active && !window.__godsEyeView.viewer.trackedEntity + ); + }), + ); + check( + 'destroyed controls cannot navigate or submit another lookup', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + const count = window.__qaSearchRequests.length; + ui._locationControls.destroy(); + ui._locationLookup.destroy(); + const before = ui._navigationGeneration; + ui._locationPills.querySelector('button').click(); + ui._resetGlobeBtn.click(); + ui._locationSearch.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + return ( + before === ui._navigationGeneration && + count === window.__qaSearchRequests.length + ); + }), + ); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/src/locationStatus.js b/src/locationStatus.js index 2dcc80fcc..1d90582c9 100644 --- a/src/locationStatus.js +++ b/src/locationStatus.js @@ -55,7 +55,10 @@ export function locationMiniStatus({ // The remaining address is the place's context ("Japan", "Minato City, // Tokyo, Japan"); the readout is ellipsised in CSS, so a long tail is // safe. A one-segment geocode ("Japan") has no context to show. - poi: segments.length > 1 ? segments.slice(1).join(', ') : 'Searched location', + poi: + segments.length > 1 + ? segments.slice(1).join(', ') + : 'Searched location', }; } diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 21f905816..51fadf752 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -3,8 +3,30 @@ const POI_KEYS = ['Q', 'W', 'E', 'R', 'T']; /** Location DOM, keyboard handling and pending row animation over supplied actions. */ export class LocationControls { - constructor({ elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc = document, requestFrame = requestAnimationFrame, cancelFrame = cancelAnimationFrame }) { - Object.assign(this, { elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc, requestFrame, cancelFrame }); + constructor({ + elements, + cities, + getExpandedCity, + onCity, + onPoi, + onSearch, + onReset, + doc = document, + requestFrame = requestAnimationFrame, + cancelFrame = cancelAnimationFrame, + }) { + Object.assign(this, { + elements, + cities, + getExpandedCity, + onCity, + onPoi, + onSearch, + onReset, + doc, + requestFrame, + cancelFrame, + }); this.removers = []; this.poiRemovers = []; this.frame = null; @@ -20,24 +42,34 @@ export class LocationControls { this.bind(pill, 'click', () => onCity(id)); elements.pills.appendChild(pill); } - this.bind(doc, 'keydown', event => { + this.bind(doc, 'keydown', (event) => { const cityId = getExpandedCity(); - if (!cityId || event.target?.matches?.('select, input, textarea') || event.target === elements.search) return; + if ( + !cityId || + event.target?.matches?.('select, input, textarea') || + event.target === elements.search + ) + return; const index = POI_KEYS.indexOf(event.key.toUpperCase()); - if (index !== -1 && index < (cities[cityId]?.pois.length || 0)) onPoi(cityId, index); + if (index !== -1 && index < (cities[cityId]?.pois.length || 0)) + onPoi(cityId, index); }); this.bind(elements.searchToggle, 'click', () => { elements.search.classList.toggle('expanded'); - if (elements.search.classList.contains('expanded')) elements.search.focus(); + if (elements.search.classList.contains('expanded')) + elements.search.focus(); }); - this.bind(elements.search, 'keydown', event => { + this.bind(elements.search, 'keydown', (event) => { if (event.key === 'Enter') void onSearch(elements.search.value); }); - for (const button of elements.resetButtons) this.bind(button, 'click', onReset); + for (const button of elements.resetButtons) + this.bind(button, 'click', onReset); } bind(element, event, handler, removers = this.removers) { if (!element) return; - const listener = (...args) => { if (!this.destroyed) return handler(...args); }; + const listener = (...args) => { + if (!this.destroyed) return handler(...args); + }; element.addEventListener(event, listener); removers.push(() => element.removeEventListener(event, listener)); } @@ -66,7 +98,12 @@ export class LocationControls { label.className = 'poi-pill-name'; label.textContent = poi.name; pill.append(key, label); - this.bind(pill, 'click', () => this.onPoi(cityId, index), this.poiRemovers); + this.bind( + pill, + 'click', + () => this.onPoi(cityId, index), + this.poiRemovers, + ); this.elements.poiRow.appendChild(pill); }); this.frame = this.requestFrame(() => { @@ -84,14 +121,17 @@ export class LocationControls { } highlightPoi(index) { if (this.destroyed) return; - for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill')) pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index); + for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill')) + pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index); } highlightCity(id) { if (this.destroyed) return; - for (const pill of this.elements.pills.querySelectorAll('.location-pill')) pill.classList.toggle('active', pill.dataset.locationId === id); + for (const pill of this.elements.pills.querySelectorAll('.location-pill')) + pill.classList.toggle('active', pill.dataset.locationId === id); } renderStatus(state) { - if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) return; + if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) + return; const lines = locationMiniStatus(state); this.elements.statusCity.textContent = lines.city; this.elements.statusPoi.textContent = lines.poi; @@ -113,7 +153,11 @@ export class LocationControls { if (this.destroyed) return; this.destroyed = true; this.cancelExpansion(); - for (const remove of [...this.poiRemovers.splice(0), ...this.removers.splice(0)]) remove(); + for (const remove of [ + ...this.poiRemovers.splice(0), + ...this.removers.splice(0), + ]) + remove(); this.orbitIndicator?.remove(); } } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 86c2465a3..928e48554 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -5,40 +5,145 @@ import { LocationControls } from './locationControls.js'; function node() { const classes = new Set(); return { - children: [], dataset: {}, listeners: new Map(), className: '', textContent: '', - classList: { add: (...names) => names.forEach(name => classes.add(name)), remove: (...names) => names.forEach(name => classes.delete(name)), contains: name => classes.has(name), toggle(name, enabled = !classes.has(name)) { if(enabled) classes.add(name); else classes.delete(name); } }, - addEventListener(name, callback) { if(!this.listeners.has(name)) this.listeners.set(name,new Set()); this.listeners.get(name).add(callback); }, - removeEventListener(name, callback) { this.listeners.get(name)?.delete(callback); }, - fire(name,event={}) { for(const callback of this.listeners.get(name)||[]) callback(event); }, - appendChild(child) { this.children.push(child); child.parent=this; }, - append(...children) { for(const child of children) if(typeof child!=='string') this.appendChild(child); }, - replaceChildren() { this.children=[]; }, - remove() { if(this.parent) this.parent.children=this.parent.children.filter(child=>child!==this); }, - querySelectorAll(selector) { return this.children.flatMap(child=>[...(child.className===selector.slice(1)?[child]:[]),...child.querySelectorAll(selector)]); }, - focus() { this.focused=true; }, + children: [], + dataset: {}, + listeners: new Map(), + className: '', + textContent: '', + classList: { + add: (...names) => names.forEach((name) => classes.add(name)), + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + toggle(name, enabled = !classes.has(name)) { + if (enabled) classes.add(name); + else classes.delete(name); + }, + }, + addEventListener(name, callback) { + if (!this.listeners.has(name)) this.listeners.set(name, new Set()); + this.listeners.get(name).add(callback); + }, + removeEventListener(name, callback) { + this.listeners.get(name)?.delete(callback); + }, + fire(name, event = {}) { + for (const callback of this.listeners.get(name) || []) callback(event); + }, + appendChild(child) { + this.children.push(child); + child.parent = this; + }, + append(...children) { + for (const child of children) + if (typeof child !== 'string') this.appendChild(child); + }, + replaceChildren() { + this.children = []; + }, + remove() { + if (this.parent) + this.parent.children = this.parent.children.filter( + (child) => child !== this, + ); + }, + querySelectorAll(selector) { + return this.children.flatMap((child) => [ + ...(child.className === selector.slice(1) ? [child] : []), + ...child.querySelectorAll(selector), + ]); + }, + focus() { + this.focused = true; + }, }; } function fixture() { - const elements={pills:node(),poiRow:node(),divider:node(),search:node(),searchToggle:node(),resetButtons:[node(),node()],statusCity:node(),statusPoi:node()}; - const doc=node();doc.createElement=node;doc.body=node(); - const cities={a:{name:'City A',pois:[{name:'First'},{name:'Second'}]},b:{name:'City B',pois:[{name:'Elsewhere'}]}}; - const calls=[];const frames=new Map();const cancelled=[];let next=0; - const controls=new LocationControls({elements,cities,getExpandedCity:()=> 'a',onCity:id=>calls.push(['city',id]),onPoi:(id,index)=>calls.push(['poi',id,index]),onSearch:query=>calls.push(['search',query]),onReset:()=>calls.push(['reset']),doc,requestFrame:fn=>{const id=next++;frames.set(id,fn);return id},cancelFrame:id=>cancelled.push(id)}); - return {elements,doc,controls,calls,frames,cancelled}; + const elements = { + pills: node(), + poiRow: node(), + divider: node(), + search: node(), + searchToggle: node(), + resetButtons: [node(), node()], + statusCity: node(), + statusPoi: node(), + }; + const doc = node(); + doc.createElement = node; + doc.body = node(); + const cities = { + a: { name: 'City A', pois: [{ name: 'First' }, { name: 'Second' }] }, + b: { name: 'City B', pois: [{ name: 'Elsewhere' }] }, + }; + const calls = []; + const frames = new Map(); + const cancelled = []; + let next = 0; + const controls = new LocationControls({ + elements, + cities, + getExpandedCity: () => 'a', + onCity: (id) => calls.push(['city', id]), + onPoi: (id, index) => calls.push(['poi', id, index]), + onSearch: (query) => calls.push(['search', query]), + onReset: () => calls.push(['reset']), + doc, + requestFrame: (fn) => { + const id = next++; + frames.set(id, fn); + return id; + }, + cancelFrame: (id) => cancelled.push(id), + }); + return { elements, doc, controls, calls, frames, cancelled }; } test('hiding a POI row cancels frame zero and rejects an already queued expansion', () => { - const f=fixture();f.controls.showPois('a');const callback=f.frames.get(0);f.controls.hidePois();callback(); - assert.deepEqual(f.cancelled,[0]);assert.equal(f.elements.poiRow.classList.contains('expanded'),false);assert.equal(f.elements.divider.classList.contains('visible'),false); + const f = fixture(); + f.controls.showPois('a'); + const callback = f.frames.get(0); + f.controls.hidePois(); + callback(); + assert.deepEqual(f.cancelled, [0]); + assert.equal(f.elements.poiRow.classList.contains('expanded'), false); + assert.equal(f.elements.divider.classList.contains('visible'), false); }); test('replacing POIs removes old click actions and presents the final row', () => { - const f=fixture();f.controls.showPois('a');const old=f.elements.poiRow.children[0];f.controls.showPois('b');old.fire('click'); - assert.deepEqual(f.calls,[]);f.elements.poiRow.children[0].fire('click');assert.deepEqual(f.calls,[['poi','b',0]]); - f.frames.get(0)();assert.equal(f.elements.poiRow.classList.contains('expanded'),false);f.frames.get(1)();assert.equal(f.elements.poiRow.classList.contains('expanded'),true); + const f = fixture(); + f.controls.showPois('a'); + const old = f.elements.poiRow.children[0]; + f.controls.showPois('b'); + old.fire('click'); + assert.deepEqual(f.calls, []); + f.elements.poiRow.children[0].fire('click'); + assert.deepEqual(f.calls, [['poi', 'b', 0]]); + f.frames.get(0)(); + assert.equal(f.elements.poiRow.classList.contains('expanded'), false); + f.frames.get(1)(); + assert.equal(f.elements.poiRow.classList.contains('expanded'), true); }); test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => { - const f=fixture();f.controls.showPois('a');f.controls.createOrbitIndicator();assert.equal(f.doc.body.children.length,1);const city=f.elements.pills.children[0];const poi=f.elements.poiRow.children[0];f.controls.destroy();f.controls.destroy(); - city.fire('click');poi.fire('click');f.elements.search.value='Place';f.elements.search.fire('keydown',{key:'Enter'});f.doc.fire('keydown',{key:'Q'});f.elements.resetButtons[0].fire('click');f.frames.get(0)();assert.deepEqual(f.calls,[]);assert.equal(f.doc.body.children.length,0); + const f = fixture(); + f.controls.showPois('a'); + f.controls.createOrbitIndicator(); + assert.equal(f.doc.body.children.length, 1); + const city = f.elements.pills.children[0]; + const poi = f.elements.poiRow.children[0]; + f.controls.destroy(); + f.controls.destroy(); + city.fire('click'); + poi.fire('click'); + f.elements.search.value = 'Place'; + f.elements.search.fire('keydown', { key: 'Enter' }); + f.doc.fire('keydown', { key: 'Q' }); + f.elements.resetButtons[0].fire('click'); + f.frames.get(0)(); + assert.deepEqual(f.calls, []); + assert.equal(f.doc.body.children.length, 0); }); test('location and POI keys route once while form controls retain typing', () => { - const f=fixture();f.doc.fire('keydown',{key:'W',target:{matches:()=>false}});f.doc.fire('keydown',{key:'Q',target:{matches:()=>true}});f.elements.resetButtons[1].fire('click');assert.deepEqual(f.calls,[['poi','a',1],['reset']]); + const f = fixture(); + f.doc.fire('keydown', { key: 'W', target: { matches: () => false } }); + f.doc.fire('keydown', { key: 'Q', target: { matches: () => true } }); + f.elements.resetButtons[1].fire('click'); + assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]); }); diff --git a/src/ui/locationSearch.js b/src/ui/locationSearch.js index 5f883ec41..b7db9c37c 100644 --- a/src/ui/locationSearch.js +++ b/src/ui/locationSearch.js @@ -1,7 +1,29 @@ /** One cancellable place lookup at a time, under the caller's camera authority. */ export class LocationSearch { - constructor({ input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }) { - Object.assign(this, { input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }); + constructor({ + input, + begin, + isCurrent, + beforeFly, + search, + onStart, + onResult, + onMissing, + onError, + onSettled, + }) { + Object.assign(this, { + input, + begin, + isCurrent, + beforeFly, + search, + onStart, + onResult, + onMissing, + onError, + onSettled, + }); this.controller = null; this.generation = 0; this.destroyed = false; @@ -19,7 +41,10 @@ export class LocationSearch { const controller = new AbortController(); this.controller = controller; const generation = ++this.generation; - const current = () => !this.destroyed && generation === this.generation && this.isCurrent(authority); + const current = () => + !this.destroyed && + generation === this.generation && + this.isCurrent(authority); this.onStart(authority); this.input.classList.add('searching'); try { diff --git a/src/ui/locationSearch.test.mjs b/src/ui/locationSearch.test.mjs index 795d32768..37b107039 100644 --- a/src/ui/locationSearch.test.mjs +++ b/src/ui/locationSearch.test.mjs @@ -6,52 +6,105 @@ function fixture() { const calls = []; const requests = []; let authority = 0; - const input = { classList: { add: name => calls.push(['add',name]), remove: name => calls.push(['remove',name]) }, blur: () => calls.push(['blur']) }; + const input = { + classList: { + add: (name) => calls.push(['add', name]), + remove: (name) => calls.push(['remove', name]), + }, + blur: () => calls.push(['blur']), + }; const search = new LocationSearch({ input, begin: () => ++authority, - isCurrent: ticket => ticket === authority, - beforeFly: ticket => { calls.push(['fly',ticket]); return true; }, - search: (query, options) => new Promise((resolve,reject) => requests.push({query,options,resolve,reject})), - onStart: ticket => calls.push(['start',ticket]), - onResult: (result,query) => calls.push(['result',result,query]), + isCurrent: (ticket) => ticket === authority, + beforeFly: (ticket) => { + calls.push(['fly', ticket]); + return true; + }, + search: (query, options) => + new Promise((resolve, reject) => + requests.push({ query, options, resolve, reject }), + ), + onStart: (ticket) => calls.push(['start', ticket]), + onResult: (result, query) => calls.push(['result', result, query]), onMissing: () => calls.push(['missing']), - onError: error => calls.push(['error',error.message]), - onSettled: ticket => calls.push(['settled',ticket]), + onError: (error) => calls.push(['error', error.message]), + onSettled: (ticket) => calls.push(['settled', ticket]), }); - return {search,requests,calls,takeCamera:()=>++authority}; + return { search, requests, calls, takeCamera: () => ++authority }; } test('a newer lookup aborts its predecessor and only its result is presented', async () => { - const f=fixture();const first=f.search.run(' First ');const second=f.search.run('Second'); - assert.equal(f.requests[0].query,'First');assert.equal(f.requests[0].options.signal.aborted,true); - assert.equal(f.requests[0].options.beforeFly(),false); - assert.equal(f.requests[1].options.beforeFly(),true); - f.requests[1].resolve({label:'Second'});await second; - f.requests[0].resolve({label:'First'});await first; - assert.deepEqual(f.calls.filter(x=>x[0]==='result'),[['result',{label:'Second'},'Second']]); - assert.equal(f.search.controller,null); + const f = fixture(); + const first = f.search.run(' First '); + const second = f.search.run('Second'); + assert.equal(f.requests[0].query, 'First'); + assert.equal(f.requests[0].options.signal.aborted, true); + assert.equal(f.requests[0].options.beforeFly(), false); + assert.equal(f.requests[1].options.beforeFly(), true); + f.requests[1].resolve({ label: 'Second' }); + await second; + f.requests[0].resolve({ label: 'First' }); + await first; + assert.deepEqual( + f.calls.filter((x) => x[0] === 'result'), + [['result', { label: 'Second' }, 'Second']], + ); + assert.equal(f.search.controller, null); }); test('another camera action revokes a pending lookup without presenting an error', async () => { - const f=fixture();const pending=f.search.run('Place');f.takeCamera(); - assert.equal(f.requests[0].options.beforeFly(),false); - f.requests[0].reject(new Error('old failure'));await pending; - assert.equal(f.calls.some(x=>x[0]==='error'||x[0]==='result'),false); + const f = fixture(); + const pending = f.search.run('Place'); + f.takeCamera(); + assert.equal(f.requests[0].options.beforeFly(), false); + f.requests[0].reject(new Error('old failure')); + await pending; + assert.equal( + f.calls.some((x) => x[0] === 'error' || x[0] === 'result'), + false, + ); }); test('missing and failed current lookups settle the exact owning generation', async () => { - const f=fixture();let pending=f.search.run('Missing');f.requests[0].resolve(null);await pending; - assert.ok(f.calls.some(x=>x[0]==='missing')); - pending=f.search.run('Failure');f.requests[1].reject(new Error('unavailable'));await pending; - assert.deepEqual(f.calls.filter(x=>x[0]==='error'),[['error','unavailable']]); - assert.deepEqual(f.calls.filter(x=>x[0]==='settled'),[['settled',1],['settled',2]]); + const f = fixture(); + let pending = f.search.run('Missing'); + f.requests[0].resolve(null); + await pending; + assert.ok(f.calls.some((x) => x[0] === 'missing')); + pending = f.search.run('Failure'); + f.requests[1].reject(new Error('unavailable')); + await pending; + assert.deepEqual( + f.calls.filter((x) => x[0] === 'error'), + [['error', 'unavailable']], + ); + assert.deepEqual( + f.calls.filter((x) => x[0] === 'settled'), + [ + ['settled', 1], + ['settled', 2], + ], + ); }); test('destruction cancels work and rejects late presentation and future requests', async () => { - const f=fixture();const pending=f.search.run('Place');f.search.destroy();f.search.destroy(); - assert.equal(f.requests[0].options.signal.aborted,true); - f.requests[0].resolve({label:'Place'});await pending;await f.search.run('Later'); - assert.equal(f.requests.length,1);assert.equal(f.calls.some(x=>x[0]==='result'||x[0]==='settled'),false); + const f = fixture(); + const pending = f.search.run('Place'); + f.search.destroy(); + f.search.destroy(); + assert.equal(f.requests[0].options.signal.aborted, true); + f.requests[0].resolve({ label: 'Place' }); + await pending; + await f.search.run('Later'); + assert.equal(f.requests.length, 1); + assert.equal( + f.calls.some((x) => x[0] === 'result' || x[0] === 'settled'), + false, + ); }); test('empty queries and refused navigation do not issue a lookup', async () => { - const f=fixture();await f.search.run(' ');f.search.begin=()=>false;await f.search.run('Place'); - assert.equal(f.requests.length,0);assert.ok(f.calls.some(x=>x[0]==='blur')); + const f = fixture(); + await f.search.run(' '); + f.search.begin = () => false; + await f.search.run('Place'); + assert.equal(f.requests.length, 0); + assert.ok(f.calls.some((x) => x[0] === 'blur')); }); From 7485565975eac714b190ef6c1a20848c29f1ddbf Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:56:23 -0700 Subject: [PATCH 17/92] refactor: expose location status parsing with its formatter --- src/ui/location.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/location.js b/src/ui/location.js index 5c593d2e0..9fb8920a5 100644 --- a/src/ui/location.js +++ b/src/ui/location.js @@ -1,3 +1,3 @@ export { LocationControls } from './locationControls.js'; export { LocationSearch } from './locationSearch.js'; -export { locationMiniStatus } from '../locationStatus.js'; +export { addressSegments, locationMiniStatus } from '../locationStatus.js'; From 63e535296438e917014e1bc344175b43d13e3087 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:58:02 -0700 Subject: [PATCH 18/92] fix: invoke Location animation scheduling through window functions --- src/ui/locationControls.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 51fadf752..29f334b5d 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -12,8 +12,8 @@ export class LocationControls { onSearch, onReset, doc = document, - requestFrame = requestAnimationFrame, - cancelFrame = cancelAnimationFrame, + requestFrame = (callback) => requestAnimationFrame(callback), + cancelFrame = (id) => cancelAnimationFrame(id), }) { Object.assign(this, { elements, From 220caf5427fdeae68588f8ba2775d45afc3ba2ef Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:00:02 -0700 Subject: [PATCH 19/92] test: follow reset and deferred navigation through Location components --- src/cockpitMarkup.test.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 7edd4aea2..515d7da64 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -468,11 +468,11 @@ test('Reset releases Contact camera ownership through its selection-preserving r assert.ok(contextRelease > resetStart); assert.ok(satelliteRelease > contextRelease); assert.match(ui, /this\._cockpitResetGlobeBtn = document\.getElementById\('cockpit-reset-globe'\)/); - assert.match( - ui, - /for \(const button of \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]\) \{[\s\S]*?addEventListener\('click', this\._globeResetHandler\)/, - 'both reset controls must delegate to the one shared reset route', - ); + const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8'); + assert.match(ui, /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/); + assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/); + assert.match(controls, /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/, + 'both reset controls delegate to the same supplied reset action'); assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/); assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/); }); @@ -496,7 +496,10 @@ test('Location navigation releases immediate routes before flight and deferred r const poiHandler = ui.slice(ui.indexOf('_onPoiClick(cityId, poiIndex) {'), ui.indexOf('_expandPOIRow(cityId) {')); assert.match(cityHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/); assert.match(poiHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/); - assert.match(ui.slice(search, search + 320), /beforeFly: \(\) => this\._reassertNavigationHandoff\(generation\)/); + assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); + assert.match(ui.slice(search, search + 180), /\.\.\.options/); + const lookup = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8'); + assert.match(lookup, /beforeFly: \(\) => current\(\) && this\.beforeFly\(authority\)/); assert.ok(voiceStart >= 0, 'voice Location must use the same handoff'); assert.match(voiceActions, /styleManager\.reassertDeferredLocationNavigation\(generation\)/); }); From 9bbb437592736b63abc919bbb4170c18799a0166 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:02:16 -0700 Subject: [PATCH 20/92] test: check searched destination and context on their respective lines --- scripts/qa-location-controls.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 20b0bf861..5a7578ef3 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -132,7 +132,8 @@ try { const ui = window.__godsEyeView.styleManager; return ( ui._searchedLocationLabel === 'Second landmark, Test city' && - ui._locationMiniPoi.textContent.includes('Second landmark') && + ui._locationMiniCity.textContent.includes('Second landmark') && + ui._locationMiniPoi.textContent === 'Test city' && ui.activeStyle === 'normal' && !ui._locationSearch.classList.contains('searching') ); From 4e33e983774c9e49bc7eed709327f10d84ca9b22 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:05:12 -0700 Subject: [PATCH 21/92] test: wait for city tiles before Location captures --- scripts/qa-location-controls.mjs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 5a7578ef3..4c3248ce8 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -166,6 +166,33 @@ try { () => !window.__godsEyeView.viewer.camera._currentFlight, { timeout: 10000 }, ); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + const settled = await page + .waitForFunction( + () => { + const scene = window.__godsEyeView.viewer.scene; + for (let index = 0; index < scene.primitives.length; index++) { + const primitive = scene.primitives.get(index); + if ( + typeof primitive.tilesLoaded === 'boolean' && + !primitive.tilesLoaded + ) + return false; + } + return true; + }, + { timeout: 60000 }, + ) + .then( + () => true, + () => false, + ); + check('visible city tiles settle before screenshots', settled); fs.mkdirSync('qa-shots/location-controls', { recursive: true }); await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' }); await page.setViewport({ width: 620, height: 900 }); From 7b9aca859739732b205ea1223911c12f850aabdb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:23:20 -0700 Subject: [PATCH 22/92] refactor: isolate Radio controls and tuner presentation --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 8 + docs/CURRENT-STATE.md | 10 + package.json | 3 +- scripts/format-scope.json | 8 +- scripts/package-boundaries.json | 225 +++++-- scripts/qa-radio.mjs | 104 +-- src/cockpitMarkup.test.mjs | 50 +- src/contextSessionOrdering.test.mjs | 23 +- src/contextTabKeyboard.test.mjs | 14 +- src/data/radio.js | 88 +-- src/firstRunExperience.test.mjs | 2 +- src/panelEscape.test.mjs | 10 +- src/radioMarkup.test.mjs | 60 +- src/ui.js | 940 +++------------------------- src/ui/radio.js | 2 + src/ui/radioBindings.js | 488 +++++++++++++++ src/ui/radioControls.js | 156 +++++ src/ui/radioControls.test.mjs | 116 ++++ src/ui/radioPresentation.js | 245 ++++++++ src/ui/radioTunerModel.js | 87 +++ 21 files changed, 1507 insertions(+), 1134 deletions(-) create mode 100644 src/ui/radio.js create mode 100644 src/ui/radioBindings.js create mode 100644 src/ui/radioControls.js create mode 100644 src/ui/radioControls.test.mjs create mode 100644 src/ui/radioPresentation.js create mode 100644 src/ui/radioTunerModel.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d16fc822..1bddf56bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup. + - Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row. - Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 78f04247d..f11735a43 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -287,3 +287,11 @@ the existing location-status formatter. Callers supply city data, search and navigation operations. The component owns DOM listeners and pending expansion; it imports no geocoder, camera engine, layer or application bootstrap. Existing camera authority and search providers remain supplied by the application. + +### Radio controls + +`ui/radio` owns Radio input, disclosures, tuner state and presentation. It +receives the existing Radio port and explicit layer/layout actions, without +importing the renderer or station providers. Pure tuner calculations retain +compatibility exports from the data layer. Disposal revokes DOM listeners and +subscriptions before ending the active tuning interaction. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 85d03ca5c..dc911cb8d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,15 @@ # God's Eye View Current State +## Radio controls + +Radio panel, compact controls and tuner presentation have a dedicated owner. +It receives playback, layer actions and layout callbacks; station ingestion, +marker placement, audio playback and camera policy stay with their existing +owners. Tuner calculations have a pure entry, with existing layer exports +preserved. Destruction removes listeners and state subscriptions before ending +tuning; a delayed Enable result cannot reveal or refocus removed controls. + + ## Location control ownership City/POI rows, search/reset bindings, location readouts and the orbit indicator diff --git a/package.json b/package.json index 57d264802..c88533b76 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "./ui/maps": "./src/ui/mapSource.js", "./ui/layers": "./src/ui/layers.js", "./ui/layers/feedback": "./src/loadingFeedback.js", - "./ui/location": "./src/ui/location.js" + "./ui/location": "./src/ui/location.js", + "./ui/radio": "./src/ui/radio.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8aa998b56..5e0a8b660 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -166,5 +166,11 @@ "src/locationStatus.js", "src/ui/locationControls.test.mjs", "src/ui/locationSearch.test.mjs", - "scripts/qa-location-controls.mjs" + "scripts/qa-location-controls.mjs", + "src/ui/radio.js", + "src/ui/radioControls.js", + "src/ui/radioBindings.js", + "src/ui/radioPresentation.js", + "src/ui/radioTunerModel.js", + "src/ui/radioControls.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9804b619e..6e9b94dce 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,27 +10,47 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "application": { - "exports": ["./application"], - "modules": ["src/app/application.js"], + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], "external": [] }, "viewer": { - "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite"], - "modules": ["build/vite.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite" + ], + "modules": [ + "build/vite.js" + ], + "external": [ + "vite-plugin-cesium" + ] }, "live-providers": { "runtime": "node", - "exports": ["./server/providers/live"], + "exports": [ + "./server/providers/live" + ], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -45,16 +65,24 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": ["ws"] + "external": [ + "ws" + ] }, "adsb-lol-source": { - "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "exports": [ + "./sources/adsb-lol" + ], + "modules": [ + "src/data/adsbLolFallback.js" + ], "external": [] }, "place-providers": { "runtime": "node", - "exports": ["./server/providers/places"], + "exports": [ + "./server/providers/places" + ], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -69,13 +97,19 @@ "external": [] }, "place-payloads": { - "exports": ["./sources/places"], - "modules": ["src/data/placeProviderPayloads.js"], + "exports": [ + "./sources/places" + ], + "modules": [ + "src/data/placeProviderPayloads.js" + ], "external": [] }, "space-providers": { "runtime": "node", - "exports": ["./server/providers/space"], + "exports": [ + "./server/providers/space" + ], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -86,13 +120,19 @@ "external": [] }, "space-requests": { - "exports": ["./sources/space"], - "modules": ["src/data/spaceProviderRequests.js"], + "exports": [ + "./sources/space" + ], + "modules": [ + "src/data/spaceProviderRequests.js" + ], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": ["./server/providers/terrain"], + "exports": [ + "./server/providers/terrain" + ], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -100,30 +140,50 @@ "external": [] }, "terrain-source": { - "exports": ["./sources/terrain"], - "modules": ["src/data/terrainHeightsProxy.js"], + "exports": [ + "./sources/terrain" + ], + "modules": [ + "src/data/terrainHeightsProxy.js" + ], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "exports": [ + "./server/providers/traffic" + ], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { - "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "exports": [ + "./sources/traffic" + ], + "modules": [ + "src/data/tomtomTiles.js" + ], "external": [] }, "firms-provider": { "runtime": "node", - "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "exports": [ + "./server/providers/firms" + ], + "modules": [ + "server/providers/firms.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": ["./server/providers/gbfs"], + "exports": [ + "./server/providers/gbfs" + ], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -132,13 +192,19 @@ "external": [] }, "gbfs-source": { - "exports": ["./sources/gbfs"], - "modules": ["src/data/gbfsSource.js"], + "exports": [ + "./sources/gbfs" + ], + "modules": [ + "src/data/gbfsSource.js" + ], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": ["./server/providers/cctv"], + "exports": [ + "./server/providers/cctv" + ], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -155,7 +221,9 @@ }, "radio-provider": { "runtime": "node", - "exports": ["./server/providers/radio"], + "exports": [ + "./server/providers/radio" + ], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -169,7 +237,9 @@ }, "overpass-provider": { "runtime": "node", - "exports": ["./server/providers/overpass"], + "exports": [ + "./server/providers/overpass" + ], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -188,7 +258,9 @@ }, "military-installations-provider": { "runtime": "node", - "exports": ["./server/providers/military-installations"], + "exports": [ + "./server/providers/military-installations" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -205,7 +277,9 @@ }, "regional-provider": { "runtime": "node", - "exports": ["./server/providers/regional"], + "exports": [ + "./server/providers/regional" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -224,7 +298,9 @@ }, "openai-provider": { "runtime": "node", - "exports": ["./server/providers/openai"], + "exports": [ + "./server/providers/openai" + ], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -244,7 +320,9 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": ["./server/standalone/key-setup"], + "exports": [ + "./server/standalone/key-setup" + ], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -255,7 +333,9 @@ "external": [] }, "place-search": { - "exports": ["./search"], + "exports": [ + "./search" + ], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -265,17 +345,27 @@ "external": [] }, "panel-disclosure": { - "exports": ["./ui/panels"], - "modules": ["src/ui/panelDisclosure.js"], + "exports": [ + "./ui/panels" + ], + "modules": [ + "src/ui/panelDisclosure.js" + ], "external": [] }, "surface-keyboard": { - "exports": ["./ui/surfaces"], - "modules": ["src/ui/surfaceKeyboard.js"], + "exports": [ + "./ui/surfaces" + ], + "modules": [ + "src/ui/surfaceKeyboard.js" + ], "external": [] }, "panel-rail-layout": { - "exports": ["./ui/layout"], + "exports": [ + "./ui/layout" + ], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -287,7 +377,9 @@ "external": [] }, "visual-input": { - "exports": ["./ui/input"], + "exports": [ + "./ui/input" + ], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -296,12 +388,19 @@ "external": [] }, "display-controls": { - "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "exports": [ + "./ui/display" + ], + "modules": [ + "src/ui/displayControls.js" + ], "external": [] }, "visual-effects": { - "exports": ["./ui/effects", "./ui/effects/bloom"], + "exports": [ + "./ui/effects", + "./ui/effects/bloom" + ], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -314,10 +413,14 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "map-source-controls": { - "exports": ["./ui/maps"], + "exports": [ + "./ui/maps" + ], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -327,7 +430,10 @@ "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -338,7 +444,9 @@ "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -346,5 +454,18 @@ "src/locationStatus.js" ], "external": [] + }, + "radio-controls": { + "exports": [ + "./ui/radio" + ], + "modules": [ + "src/ui/radio.js", + "src/ui/radioControls.js", + "src/ui/radioBindings.js", + "src/ui/radioPresentation.js", + "src/ui/radioTunerModel.js" + ], + "external": [] } } diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 50d5b50c6..bcc5524d1 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -281,7 +281,7 @@ async function main() { fullPlayEnabled: !document.getElementById('radio-play-btn').disabled, fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'), tunerVisible: !document.getElementById('radio-tuner').hidden, - tunerCount: window.__godsEyeView.styleManager._radioTunerStations.length, + tunerCount: window.__godsEyeView.styleManager._radioControls._radioTunerStations.length, tunerLabel: document.getElementById('radio-tuner-band-label').textContent, }; }); @@ -332,19 +332,19 @@ async function main() { const staticResult = module.setTuningStatic(true); const filterBefore = module.getUIState().filter; const filterControl = document.getElementById('radio-filter'); - const originalPinned = style._radioTunerBandPinnedForNavigation; - const originalPool = style._radioTunerPool; - style._radioTunerBandPinnedForNavigation = true; + const originalPinned = style._radioControls._radioTunerBandPinnedForNavigation; + const originalPool = style._radioControls._radioTunerPool; + style._radioControls._radioTunerBandPinnedForNavigation = true; filterControl.value = filterBefore === 'news' ? 'all' : 'news'; filterControl.dispatchEvent(new Event('change', { bubbles: true })); const rejectedUIFilter = { moduleFilter: module.getUIState().filter, controlFilter: filterControl.value, - pinned: style._radioTunerBandPinnedForNavigation, - poolIdentityPreserved: style._radioTunerPool === originalPool, + pinned: style._radioControls._radioTunerBandPinnedForNavigation, + poolIdentityPreserved: style._radioControls._radioTunerPool === originalPool, }; - style._radioTunerBandPinnedForNavigation = originalPinned; - style._radioTunerPool = originalPool; + style._radioControls._radioTunerBandPinnedForNavigation = originalPinned; + style._radioControls._radioTunerPool = originalPool; const filterResult = module.setFilter('news'); const toggleResult = await module.togglePlayback({ origin: 'user' }); const directSelect = module.selectStation(stationId, { @@ -2719,7 +2719,7 @@ async function main() { const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect(); const sliderRect = slider.getBoundingClientRect(); const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect(); - const count = style._radioTunerStations.length; + const count = style._radioControls._radioTunerStations.length; const usable = sliderRect.width - 14; const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1); const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, { @@ -2745,7 +2745,7 @@ async function main() { }; }; const playsBefore = window.__qaRadioPlayCalls.length; - const frozenSignature = style._radioTunerBandSignature; + const frozenSignature = style._radioControls._radioTunerBandSignature; pointer('pointerdown', xFor(0)); const left = snapshot(); const centerCoordinate = (count - 1) / 2; @@ -2765,14 +2765,14 @@ async function main() { visible: !document.getElementById('radio-tuner').hidden, stationCount: count, sliderMax: Number(slider.max), - bandFrozen: style._radioTunerBandSignature === frozenSignature, + bandFrozen: style._radioControls._radioTunerBandSignature === frozenSignature, left, center, shifted, right, - leftExpectedId: style._radioTunerStations[0]?.id || null, - centerExpectedId: style._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null, - rightExpectedId: style._radioTunerStations[count - 1]?.id || null, + leftExpectedId: style._radioControls._radioTunerStations[0]?.id || null, + centerExpectedId: style._radioControls._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null, + rightExpectedId: style._radioControls._radioTunerStations[count - 1]?.id || null, centerExpected: Math.floor(centerCoordinate + 0.5), tapeDelta, needleDelta, @@ -2817,7 +2817,7 @@ async function main() { const slider = document.getElementById('radio-tuner-slider'); // Capture a fresh gesture's complete presentation anchor before moving // to a different absolute directory station. - const beforeStations = [...style._radioTunerStations]; + const beforeStations = [...style._radioControls._radioTunerStations]; const sliderRect = slider.getBoundingClientRect(); const xFor = (index) => sliderRect.left + 7 + (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1); @@ -2834,7 +2834,7 @@ async function main() { const flyToCalls = []; camera.flyTo = (options) => flyToCalls.push(options); const before = { - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, ids: beforeStations.map((station) => station.id), slot: Number(slider.value), selectedId: radio.getUIState().selected?.id || null, @@ -2848,10 +2848,10 @@ async function main() { .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null; const flyToCallsBeforeCancel = flyToCalls.length; pointer('pointercancel', targetIndex); - const afterStations = style._radioTunerStations; + const afterStations = style._radioControls._radioTunerStations; const afterState = radio.getUIState(); const after = { - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, ids: afterStations.map((station) => station.id), slot: Number(slider.value), selectedId: afterState.selected?.id || null, @@ -2972,7 +2972,7 @@ async function main() { const tunerRefreshTarget = await page.evaluate(() => { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); - const stations = gev.styleManager._radioTunerStations; + const stations = gev.styleManager._radioControls._radioTunerStations; const targetIndex = 1; const rect = slider.getBoundingClientRect(); const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1); @@ -3055,7 +3055,7 @@ async function main() { const max = Number(slider.max); const direction = startSlot < max ? 1 : -1; const targetIndex = startSlot + direction; - const targetId = gev.styleManager._radioTunerStations[targetIndex]?.id || null; + const targetId = gev.styleManager._radioControls._radioTunerStations[targetIndex]?.id || null; const rect = slider.getBoundingClientRect(); const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max); const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, { @@ -3106,18 +3106,18 @@ async function main() { const slider = document.getElementById('radio-tuner-slider'); const targetIndex = 11; slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 })); - const station = gev.styleManager._radioTunerStations[targetIndex]; + const station = gev.styleManager._radioControls._radioTunerStations[targetIndex]; const rect = slider.getBoundingClientRect(); const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max)); window.__qaRadioDelayNextPlay = true; return { id: station.id, targetIndex, - count: gev.styleManager._radioTunerStations.length, - frozenSignature: gev.styleManager._radioTunerBandSignature, + count: gev.styleManager._radioControls._radioTunerStations.length, + frozenSignature: gev.styleManager._radioControls._radioTunerBandSignature, startX: rect.left + 7 + (rect.width - 14) * currentRatio, targetX: rect.left + 7 + (rect.width - 14) * targetIndex - / Math.max(1, gev.styleManager._radioTunerStations.length - 1), + / Math.max(1, gev.styleManager._radioControls._radioTunerStations.length - 1), y: rect.top + rect.height / 2, }; }); @@ -3135,11 +3135,11 @@ async function main() { audioState: state.audioState, tuningStatic: state.tuningStatic, awaiting: state.tuningAwaitingStationId, - signatureStable: gev.styleManager._radioTunerBandSignature === target.frozenSignature, - selectedIndex: gev.styleManager._radioTunerStations.findIndex((item) => item.id === state.selected?.id), + signatureStable: gev.styleManager._radioControls._radioTunerBandSignature === target.frozenSignature, + selectedIndex: gev.styleManager._radioControls._radioTunerStations.findIndex((item) => item.id === state.selected?.id), sliderSlot: Number(slider.value), ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), - pinned: gev.styleManager._radioTunerBandPinnedForNavigation, + pinned: gev.styleManager._radioControls._radioTunerBandPinnedForNavigation, }; }, tunerCommitTarget); check( @@ -3183,7 +3183,7 @@ async function main() { y: rect.top + rect.height / 2, ratio, usableWidth: rect.width - 14, - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, }; }); const microDragSamples = []; @@ -3199,7 +3199,7 @@ async function main() { pixelDelta, slot: Number(slider.value), ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, }; }, delta)); } @@ -3238,9 +3238,9 @@ async function main() { const gev = window.__godsEyeView; const style = gev.styleManager; const before = { - signature: style._radioTunerBandSignature, - ids: style._radioTunerStations.map((station) => station.id), - selectedIndex: style._radioTunerStations.findIndex((station) => station.id === selectedId), + signature: style._radioControls._radioTunerBandSignature, + ids: style._radioControls._radioTunerStations.map((station) => station.id), + selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId), needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), }; const current = gev.viewer.camera.positionCartographic; @@ -3256,11 +3256,11 @@ async function main() { }); gev.viewer.camera.changed.raiseEvent(); await new Promise((resolve) => setTimeout(resolve, 400)); - const stations = gev.styleManager._radioTunerStations; + const stations = gev.styleManager._radioControls._radioTunerStations; const selectedIndex = stations.findIndex((station) => station.id === selectedId); return { before, - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, ids: stations.map((station) => station.id), count: stations.length, selectedIndex, @@ -3279,14 +3279,14 @@ async function main() { const nextNeedleBefore = await page.evaluate(() => { const style = window.__godsEyeView.styleManager; const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id; - const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId); - const selectedPoolIndex = style._radioTunerPool.findIndex((station) => station.id === selectedId); - const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioTunerPool.length; + const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId); + const selectedPoolIndex = style._radioControls._radioTunerPool.findIndex((station) => station.id === selectedId); + const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioControls._radioTunerPool.length; return { - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, selectedIndex, - count: style._radioTunerStations.length, - expectedId: style._radioTunerPool[expectedPoolIndex]?.id || null, + count: style._radioControls._radioTunerStations.length, + expectedId: style._radioControls._radioTunerPool[expectedPoolIndex]?.id || null, }; }); await page.$eval('#radio-next-btn', (button) => button.click()); @@ -3294,15 +3294,15 @@ async function main() { const nextNeedleAfter = await page.evaluate(() => { const style = window.__godsEyeView.styleManager; const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id; - const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId); + const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId); return { - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, selectedId, selectedIndex, - count: style._radioTunerStations.length, + count: style._radioControls._radioTunerStations.length, sliderSlot: Number(document.getElementById('radio-tuner-slider').value), ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), - pinned: style._radioTunerBandPinnedForNavigation, + pinned: style._radioControls._radioTunerBandPinnedForNavigation, }; }); check( @@ -3327,8 +3327,8 @@ async function main() { const markerId = () => gev.viewer.entities.values .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null; const startIndex = Number(slider.value); - const expectedIndex = Math.min(style._radioTunerStations.length - 1, startIndex + 1); - const expectedId = style._radioTunerStations[expectedIndex]?.id || null; + const expectedIndex = Math.min(style._radioControls._radioTunerStations.length - 1, startIndex + 1); + const expectedId = style._radioControls._radioTunerStations[expectedIndex]?.id || null; const playsBefore = window.__qaRadioPlayCalls.length; key('keydown', 'ArrowRight'); const preview = { @@ -3353,7 +3353,7 @@ async function main() { tuningActive: radio.getUIState().tuningActive, playDelta: window.__qaRadioPlayCalls.length - committedPlays, }; - const count = style._radioTunerStations.length; + const count = style._radioControls._radioTunerStations.length; const pageStep = Math.max(1, Math.round((count - 1) / 10)); const previewAndCancel = (value, expectedSlot) => { const beforePlays = window.__qaRadioPlayCalls.length; @@ -3405,7 +3405,7 @@ async function main() { const radio = gev.dataManager.layers.get('radio').module; const slider = document.getElementById('radio-tuner-slider'); const rect = slider.getBoundingClientRect(); - const count = style._radioTunerStations.length; + const count = style._radioControls._radioTunerStations.length; const centerIndex = Math.floor((count - 1) / 2 + 0.5); const clientX = rect.left + rect.width / 2; slider.dispatchEvent(new PointerEvent('pointerdown', { @@ -3425,7 +3425,7 @@ async function main() { ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), markerId: gev.viewer.entities.values .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null, - expectedId: style._radioTunerStations[centerIndex]?.id || null, + expectedId: style._radioControls._radioTunerStations[centerIndex]?.id || null, }; slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, @@ -3450,7 +3450,7 @@ async function main() { const fullPoolNavigation = await page.evaluate(() => { const gev = window.__godsEyeView; const radio = gev.dataManager.layers.get('radio').module; - const pool = gev.styleManager._radioTunerPool; + const pool = gev.styleManager._radioControls._radioTunerPool; const ids = pool.map((station) => station.id); const selectWithoutPlayback = (index) => radio.selectStation(ids[index], { autoplay: false, @@ -3500,7 +3500,7 @@ async function main() { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); const rect = slider.getBoundingClientRect(); - const count = gev.styleManager._radioTunerStations.length; + const count = gev.styleManager._radioControls._radioTunerStations.length; const current = Number(slider.value); const target = Math.min(count - 1, current + 4); const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1); @@ -3695,7 +3695,7 @@ async function main() { Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations').clustering.enabled = true; radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false }); - const pool = gev.styleManager._radioTunerPool; + const pool = gev.styleManager._radioControls._radioTunerPool; const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id); window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length]; window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length]; diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 515d7da64..355dfc040 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,3 +1,6 @@ +import { readFileSync as readRadioSource } from 'node:fs'; +const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); +const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -248,8 +251,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/); assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/); assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/); - assert.match(ui, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/); - assert.match(ui, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/); + assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/); + assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/); assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/); assert.doesNotMatch(ui, /--cockpit-display-tab-width/); // These two widths size Cockpit Radio and nothing else. They were named for @@ -307,7 +310,7 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/); assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/); assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/); - assert.match(ui, /!this\._preservePanelStateDuringLayerClear[\s\S]*?this\.setPanelCollapsed\('radio-panel', true\)/); + assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/); assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); }); @@ -320,16 +323,13 @@ test('Cockpit Display portal retains both scroll owners across round trips', () }); test('Cockpit side surfaces behave as two single-expanded accordions', () => { - assert.match( - ui, - /if \(displayOpen \|\| radioOpen\) this\.cockpitView\?\.setSignalCollapsed\(true\);/, + assert.match(radioBindings, + /if \(displayOpen \|\| radioOpen\) this\.actions\.setSignalCollapsed\(true\);/, ); - assert.match( - ui, + assert.match(radioBindings, /'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/, ); - assert.match( - ui, + assert.match(radioBindings, /'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/, ); assert.match( @@ -341,13 +341,11 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => { /nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/, 'closing Data Layers must restore Contact only after an automatic collapse', ); - assert.match( - ui, + assert.match(ui, /const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/, 'Contact expansion must notify only on a collapsed-to-expanded transition', ); - assert.match( - ui, + assert.match(ui, /const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/, 'Live Signals expansion must notify only on a collapsed-to-expanded transition', ); @@ -356,12 +354,12 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => { /setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/, ); assert.match( - ui, - /!displayOpen && !radioOpen[\s\S]*?this\.cockpitView\?\.active[\s\S]*?!this\.cockpitView\.signalUserCollapsed[\s\S]*?setSignalCollapsed\(false\)/, + radioBindings, + /!displayOpen && !radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/, 'Live Signals should reopen only after both utility panels close and no manual collapse is retained', ); assert.match( - ui, + radioBindings, /event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/, 'left-side interactions must not collapse the independent Cockpit utilities', ); @@ -505,18 +503,18 @@ test('Location navigation releases immediate routes before flight and deferred r }); test('Cockpit Radio station changes preserve first-person camera ownership', () => { - const cycleHelper = ui.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/); + const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/); assert.ok(cycleHelper, 'shared Radio cycle helper is missing'); assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/); - assert.match(ui, /_radioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1\)\)/); - assert.match(ui, /_contextRadioMiniNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1\)\)/); + assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/); + assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/); assert.match( - ui, - /_cockpitRadioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/, + radioBindings, + /listen\(this\._cockpitRadioPrevBtn, 'click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/, ); assert.match( - ui, - /_cockpitRadioNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/, + radioBindings, + /listen\(this\._cockpitRadioNextBtn, 'click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/, ); }); @@ -653,9 +651,9 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1); assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/); assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/); - assert.match(ui, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/); + assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/); assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/); - assert.match(ui, /if \(displayOpen\) this\._revealCockpitStyleParameters\(\);/); + assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/); assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/); assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/); assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/); diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 8aff666fd..e9f8ef8d7 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -1,3 +1,7 @@ +import { readFileSync as readRadioSource } from 'node:fs'; +const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); +const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); // Source-contract pins for StyleManager's Context-session handler ordering. // ui.js cannot be imported headlessly (it touches the DOM at module scope), so // these read the shipped source — the idiom established by cockpitMarkup / @@ -173,23 +177,24 @@ test('every user-facing Context exit route settles through the failure surface', }); test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => { - const radioControls = src.slice( - src.indexOf('const toggleRadio = async (trigger) => {'), - src.indexOf("this._radioFilter?.addEventListener('change'"), + const radioControls = radioBindings.slice( + radioBindings.indexOf('const toggleRadio = async (trigger) => {'), + radioBindings.indexOf("this.listen(this._radioFilter, 'change'"), ); - assert.match(radioControls, /await this\._runUserFacingContextAction\(/); + assert.match(radioControls, /await this\.actions\.runUserAction\(/); assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/); - assert.match(radioControls, /if \(toggled === false\) return/); + assert.match(radioControls, /if \(this\.destroyed \|\| toggled === false\) return/); }); test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => { - const radioControls = src.slice( - src.indexOf('const toggleRadio = async (trigger) => {'), - src.indexOf("this._radioFilter?.addEventListener('change'"), + const radioControls = radioBindings.slice( + radioBindings.indexOf('const toggleRadio = async (trigger) => {'), + radioBindings.indexOf("this.listen(this._radioFilter, 'change'"), ); assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/); assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/); - assert.equal((src.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 2); + assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1); + assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/); }); test('right-rail context entry is transactional: activation result gates the mode', () => { diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 1e58efa06..092d15fa3 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,3 +1,7 @@ +import { readFileSync as readRadioSource } from 'node:fs'; +const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); +const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -136,14 +140,8 @@ test('Context async action buttons remain focused while busy', () => { ui.indexOf('_initGlobalContextPanel() {'), ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')), ); - const radio = ui.slice( - ui.indexOf('const toggleRadio = async (trigger) => {'), - ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')), - ); - const radioSync = ui.slice( - ui.indexOf('_renderRadioState(state) {'), - ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')), - ); + const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,')); + const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)')); const clear = ui.slice( ui.indexOf('clearSelectedLayers() {'), ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')), diff --git a/src/data/radio.js b/src/data/radio.js index bc7c57f65..0f28ce8d2 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -1,3 +1,4 @@ +export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from '../ui/radioTunerModel.js'; /** * Internet-radio station directory and direct-playback layer. * @@ -945,93 +946,6 @@ export function radioStationIdFromPick(picked) { return null; } -/** Map an integer tuner slot directly to one available directory station. */ -export function radioTunerSlot(value, stationCount) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 }; - const max = Math.max(0, count - 1); - const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0))); - return { - slot, - max, - locked: true, - stationIndex: slot, - leftIndex: slot, - rightIndex: slot, - }; -} - -/** Snap a tuner release to the nearest available directory station. */ -export function radioTunerCommitSlot(value, stationCount) { - return radioTunerSlot(value, stationCount); -} - -/** Map one pointer coordinate to continuous absolute directory progress. */ -export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 }; - if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 }; - const inset = Math.max(0, Number(insetPx) || 0); - const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2); - const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth)); - const coordinate = ratio * (count - 1); - return { - ratio, - coordinate, - stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))), - }; -} - -/** Build a bounded virtual tuner tape around one continuous directory coordinate. */ -export function buildRadioTunerTicks(coordinate, stationCount, width, { - insetPx = 7, - minPitchPx = 14, - speedFactor = 5, - overscan = 2, - labelStep = 6, -} = {}) { - const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - const dialWidth = Math.max(0, Number(width) || 0); - const inset = Math.max(0, Number(insetPx) || 0); - const usableWidth = Math.max(0, dialWidth - inset * 2); - if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 }; - const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0)); - if (count === 1) { - return { - ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }], - needleX: inset + usableWidth / 2, - pitchPx: Math.max(1, Number(minPitchPx) || 14), - ratio: 0.5, - }; - } - const directoryStep = usableWidth / (count - 1); - const pitchPx = Math.max( - Math.max(1, Number(minPitchPx) || 14), - directoryStep * Math.max(1, Number(speedFactor) || 5), - ); - const needleX = inset + directoryStep * value; - const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0); - const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx)); - const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx)); - const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5))); - const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6)); - const labelWidth = Math.max(2, String(count).length); - const ticks = []; - for (let stationIndex = first; stationIndex <= last; stationIndex += 1) { - const channel = stationIndex + 1; - const current = stationIndex === currentIndex; - const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0; - ticks.push({ - stationIndex, - channel, - xPx: needleX + pitchPx * (stationIndex - value), - current, - label: labelled ? String(channel).padStart(labelWidth, '0') : '', - }); - } - return { ticks, needleX, pitchPx, ratio: value / (count - 1) }; -} - /** Plan a station-centered camera move that preserves altitude and view angle. */ export function radioStationCameraPlan(station, cameraState = {}) { const targetLat = Number(station?.lat); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index c45446183..f127e88b5 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -263,7 +263,7 @@ test('an overlay with NO class to watch still disarms the launcher', () => { }); test('one ESC does one thing — the radio disclosure stops the launcher outright', () => { - const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); // stopPropagation() does NOT stop later listeners on the SAME document, so the diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index 3c7245688..6b44052fc 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -1,3 +1,7 @@ +import { readFileSync as readRadioSource } from 'node:fs'; +const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); +const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -155,16 +159,16 @@ test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => { assert.match( - source, + radioBindings, /const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, ); assert.match( - source, + radioBindings, /const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/, 'Cockpit utility disclosures clear their own focus when Escape closes them', ); assert.match( - source, + radioBindings, /_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/, 'compact Radio disclosure clears its own focus when Escape closes it', ); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index d360ec2f1..91a86b465 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,3 +1,7 @@ +import { readFileSync as readRadioSource } from 'node:fs'; +const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); +const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; @@ -249,19 +253,19 @@ test('Radio is nested inside Context with separate disclosure and power controls assert.match(css, /--radio-broadcast-opacity: \.17/); assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/); assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/); - assert.match(ui, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/); - assert.match(ui, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/); - const cycleStart = ui.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>'); - const cycleMethod = ui.slice(cycleStart, ui.indexOf('const toggleRadio', cycleStart)); + assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/); + assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/); + const cycleStart = radioBindings.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>'); + const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart)); assert.doesNotMatch(cycleMethod, /refreshTunerBand/); - assert.match(ui, /_radioTunerBandPinnedForNavigation = true/); - assert.match(ui, /viewer\?\.canvas\?\.addEventListener\('pointerdown', releaseNavigationBand/); - assert.match(ui, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/); - assert.match(ui, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/); - assert.match(ui, /radioLayer\.cancelTuning\(\)/); - assert.match(ui, /classList\.remove\('radio-broadcasting'\)/); - assert.match(ui, /radioLayer\.getTunerStations\(750\)/); - assert.match(ui, /radioTunerPointerPosition\(/); + assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/); + assert.match(radioBindings, /listen\(this\.canvas, 'pointerdown', releaseNavigationBand/); + assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/); + assert.match(radioBindings, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/); + assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/); + assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/); + assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/); + assert.match(radioBindings, /radioTunerPointerPosition\(/); assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/); assert.match(css, /#global-context-panel #radio-panel\.collapsed/); assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/); @@ -277,16 +281,15 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb assert.match(voice, /'radio'/); assert.match(voice, /name:\s*'control_radio'/); assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']); - const enableStart = ui.lastIndexOf('\n _initRadioPanel()'); - const enableMethod = ui.slice(enableStart, ui.indexOf('\n _renderRadioState(state)', enableStart)); + const enableMethod = radioBindings; assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i); assert.match(enableMethod, /contextRadioToggleBtn/); assert.match(enableMethod, /contextRadioMiniEnableBtn/); assert.match(enableMethod, /contextRadioDetailsBtn/); assert.match(enableMethod, /contextRadioMiniCloseBtn/); assert.match(enableMethod, /contextRadioMiniPlayBtn/); - const disclosureStart = enableMethod.indexOf('this._contextRadioToggleBtn?.addEventListener'); - const disclosureEnd = enableMethod.indexOf("this._radioFilter?.addEventListener", disclosureStart); + const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,'); + const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart); const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd); assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing'); assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/); @@ -304,32 +307,29 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb }); test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => { - const revealStart = ui.indexOf('\n async _revealRadioPanelInsideContext'); - const revealEnd = ui.indexOf('\n /**', revealStart + 10); - const revealMethod = ui.slice(revealStart, revealEnd); + const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext'); + const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10); + const revealMethod = radioControlsSource.slice(revealStart, revealEnd); assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing'); assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/); assert.match(revealMethod, /scroller\.scrollTo\(\{ top: next, behavior: reducedMotion \? 'auto' : 'smooth' \}\)/); assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/); assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/); - const syncStart = ui.indexOf('\n _syncContextRadioLauncherState()'); - const syncEnd = ui.indexOf('\n /**', syncStart + 10); - const syncMethod = ui.slice(syncStart, syncEnd); + const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()'); + const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10); + const syncMethod = radioControlsSource.slice(syncStart, syncEnd); assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing'); assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded', String\(radioExpanded\)/); assert.match(syncMethod, /aria-controls', 'context-radio-mini'[\s\S]*?aria-expanded', String\(compactOpen\)/); - const renderStart = ui.indexOf('\n _renderRadioState(state)'); - const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart)); + const renderMethod = radioPresentation; assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/); assert.doesNotMatch(renderMethod, /compact Radio controls/); assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/); }); test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => { - const renderStart = ui.indexOf('\n _renderRadioState(state)'); - const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart)); - assert.ok(renderStart >= 0, 'Radio render method is missing'); + const renderMethod = radioPresentation; assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s); assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s); assert.match(ui, /contextRadioDetailsBtn/); @@ -346,8 +346,8 @@ test('successful explicit user playback hands the speaker from voice to Radio', assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying); assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/); assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/); - assert.match(ui, /togglePlayback\(\{ origin: 'user' \}\)/); - assert.match(ui, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/); - assert.match(ui, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/); + assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/); + assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/); + assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/); assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/); }); diff --git a/src/ui.js b/src/ui.js index a7abdff95..634c0e5b3 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,3 +1,4 @@ +import { RadioControls } from './ui/radio.js'; import { LocationControls, LocationSearch } from './ui/location.js'; import { bindClearLayersControl } from './ui/layers.js'; import { createMapSourceControls } from './ui/mapSource.js'; @@ -61,12 +62,7 @@ import militaryFlightsLayer from './data/militaryFlights.js'; import { isTr3b, toggleTr3b } from './data/tr3bRegistry.js'; import satellitesLayer from './data/satellites.js'; import cctvLayer from './data/cctv.js'; -import radioLayer, { - buildRadioTunerTicks, - radioTunerCommitSlot, - radioTunerPointerPosition, - radioTunerSlot, -} from './data/radio.js'; +import radioLayer from './data/radio.js'; import bikeshareLayer from './data/bikeshare.js'; import aisLiveVesselsLayer from './data/aisLiveVessels.js'; import militaryAwarenessLayer from './data/militaryAwareness.js'; @@ -2089,25 +2085,6 @@ export class StyleManager { this._clearSelectedLayersManagerPromise = null; this._dataManager = null; this._cctvUnsubscribe = null; - this._radioUnsubscribe = null; - this._radioState = null; - this._radioCategorySignature = ''; - this._radioTunerStations = []; - this._radioTunerPool = []; - this._radioTunerDragging = false; - this._radioTunerDragStartSlot = 0; - this._radioTunerDragSnapshot = null; - this._radioTunerLastSlot = 0; - this._radioTunerDragDirection = 0; - this._radioTunerCoordinate = 0; - this._radioTunerPointerId = null; - this._radioTunerKeyboardKey = null; - this._radioTunerAbort = null; - this._radioTunerBandSignature = ''; - this._radioTunerSelectedId = null; - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerCameraRemove = null; - this._refreshRadioTunerBand = null; this._cctvState = null; this._cctvSummaryTypingTimer = null; this._lastCctvSummaryText = ''; @@ -4208,15 +4185,7 @@ export class StyleManager { if (typeof cctvLayer.getUIState === 'function') { this._renderCctvState(cctvLayer.getUIState()); } - if (this._radioUnsubscribe) { - this._radioUnsubscribe(); - this._radioUnsubscribe = null; - } - if (typeof radioLayer.subscribe === 'function') { - this._radioUnsubscribe = radioLayer.subscribe((state) => { - this._renderRadioState(state); - }); - } + this._radioControls.connect(); if (!this._awarenessSelectedHandler) { this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false); this._awarenessClearedHandler = (event) => this._persistAwarenessSelection(event, true); @@ -5030,826 +4999,83 @@ export class StyleManager { /** Wire the independent Radio companion controls. */ _initRadioPanel() { - if (!this._radioPanel) return; - this._radioTunerAbort?.abort(); - this._radioTunerAbort = new AbortController(); - const tunerListenerOptions = { signal: this._radioTunerAbort.signal }; - const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { - const open = Boolean(expanded); - this._contextRadioDock?.classList.toggle('disclosure-open', open); - if (this._contextRadioMini) this._contextRadioMini.hidden = !open; - this._syncContextRadioLauncherState(); - if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true }); - }; - this._setRadioDisclosure = setRadioDisclosure; - const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => { - const displayOpen = kind === 'display' && Boolean(expanded); - const radioOpen = kind === 'radio' && Boolean(expanded); - if (displayOpen || radioOpen) this.cockpitView?.setSignalCollapsed(true); - if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen; - if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen; - this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control') - ?.classList.toggle('is-expanded', displayOpen); - this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control') - ?.classList.toggle('is-expanded', radioOpen); - this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen)); - this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen)); - if (displayOpen) this._revealCockpitStyleParameters(); - if (this._cockpitDisplayToggleBtn) { - const action = displayOpen ? 'Collapse' : 'Expand'; - this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀'; - this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`); - this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`; - } - if (this._cockpitRadioToggleBtn) { - const action = radioOpen ? 'Collapse' : 'Expand'; - this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀'; - this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`); - this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`; - } - if (!expanded && returnFocus) { - (kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn) - ?.focus({ preventScroll: true }); - } - if (!displayOpen && !radioOpen - && this.cockpitView?.active - && !this.cockpitView.signalUserCollapsed) { - this.cockpitView.setSignalCollapsed(false); - } - this.cockpitView?.scheduleContextLayout(); - }; - this._setCockpitDisclosure = setCockpitDisclosure; - const syncTunerTape = (coordinate) => { - const scale = this._radioTuner?.querySelector('.radio-tuner-scale'); - const dial = this._radioTuner?.querySelector('.radio-tuner-dial'); - if (!scale || !dial) return null; - const model = buildRadioTunerTicks( - coordinate, - this._radioTunerStations.length, - dial.getBoundingClientRect().width, - ); - while (scale.children.length < model.ticks.length) { - const tick = document.createElement('span'); - tick.className = 'radio-tuner-tick'; - scale.append(tick); - } - while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove(); - model.ticks.forEach((entry, index) => { - const tick = scale.children[index]; - tick.style.left = `${entry.xPx}px`; - tick.textContent = entry.label; - tick.dataset.stationIndex = String(entry.stationIndex); - tick.classList.toggle('is-current', entry.current); - }); - scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); - return model; - }; - const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => { - const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length); - const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null; - const resolvedCoordinate = this._radioTunerStations.length <= 1 - ? 0 - : Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0)); - const ratio = this._radioTunerStations.length === 1 - ? 0.5 - : resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1); - this._radioTunerCoordinate = resolvedCoordinate; - this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); - this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic)); - syncTunerTape(resolvedCoordinate); - if (this._radioTunerValue) { - this._radioTunerValue.textContent = station - ? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` - : 'NO STATIONS'; - } - if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE'; - if (this._radioTunerSlider) { - this._radioTunerSlider.setAttribute('aria-valuetext', station - ? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}` - : 'No station available'); - } - if (syncStatic) radioLayer.previewTuningStation(station?.id || null, { rotate }); - return station; - }; - const setTunerDirectory = (pool) => { - this._radioTunerPool = [...pool]; - this._radioTunerStations = [...pool]; - this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|'); - if (this._radioTunerSlider) { - this._radioTunerSlider.min = '0'; - this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1)); - this._radioTunerSlider.step = '1'; - } - }; - const refreshTunerBand = ({ force = false } = {}) => { - if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false; - const selectedId = this._radioState?.selected?.id || null; - const pool = radioLayer.getTunerStations(750); - const poolSignature = pool.map((station) => station.id).join('|'); - const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|'); - if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false; - setTunerDirectory(pool); - this._radioTunerSelectedId = selectedId; - const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId); - const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length); - this._radioTunerSlider.value = String(slot.slot); - this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; - tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); - return true; - }; - this._refreshRadioTunerBand = refreshTunerBand; - const beginTuner = () => { - if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false; - refreshTunerBand(); - if (!this._radioTunerStations.length || !radioLayer.beginTuning()) return false; - // A tuner-owned camera preview must never replace the frozen directory. - // Only an explicit globe pointer/wheel gesture releases camera pinning. - this._radioTunerBandPinnedForNavigation = true; - this._radioTunerDragging = true; - this._radioTuner?.classList.add('is-dragging'); - const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id); - const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length); - this._radioTunerSlider.value = String(slot.slot); - this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; - this._radioTunerDragStartSlot = slot.slot; - this._radioTunerDragSnapshot = { - stations: [...this._radioTunerStations], - bandSignature: this._radioTunerBandSignature, - selectedId: this._radioTunerSelectedId, - slot: slot.slot, - coordinate: this._radioTunerCoordinate, - }; - this._radioTunerLastSlot = slot.slot; - this._radioTunerDragDirection = 0; - tunerPreview({ coordinate: this._radioTunerCoordinate }); - return true; - }; - const finishTuner = (commit) => { - if (!this._radioTunerDragging) return; - const dragSnapshot = this._radioTunerDragSnapshot; - if (!commit && dragSnapshot) { - this._radioTunerStations = [...dragSnapshot.stations]; - this._radioTunerPool = [...dragSnapshot.stations]; - this._radioTunerBandSignature = dragSnapshot.bandSignature; - this._radioTunerSelectedId = dragSnapshot.selectedId; - if (this._radioTunerSlider) { - const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length); - this._radioTunerSlider.max = String(startSlot.max); - this._radioTunerSlider.value = String(startSlot.slot); - } - this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate) - ? dragSnapshot.coordinate - : dragSnapshot.slot; - } else if (!commit && this._radioTunerSlider) { - this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); - this._radioTunerCoordinate = this._radioTunerDragStartSlot; - } - let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit }); - if (commit && !station && this._radioTunerSlider) { - const snapped = radioTunerCommitSlot( - this._radioTunerSlider.value, - this._radioTunerStations.length, - ); - this._radioTunerSlider.value = String(snapped.slot); - this._radioTunerCoordinate = snapped.stationIndex; - station = tunerPreview({ coordinate: this._radioTunerCoordinate }); - } - let result = null; - if (commit && station) { - // Keep the exact band used by the drag so the selected channel cannot - // jump to a refreshed catalog slot while its camera flight settles. - this._radioTunerBandPinnedForNavigation = true; - result = radioLayer.commitTuningStation(station.id, { origin: 'user' }); - } else if (!commit) { - radioLayer.cancelTuning(); - } else { - radioLayer.endTuning(); - } - // Radio emits selection/tuning state synchronously. Keep both the logical - // drag and the no-transition class active until that state has settled, - // then restore the exact selected slot before permitting CSS motion. - this._radioTunerDragging = false; - this._radioTunerPointerId = null; - this._radioTunerKeyboardKey = null; - if (commit && (!result || result.ok)) refreshTunerBand(); - this._radioTunerDragSnapshot = null; - // Flush the snapped position while transitions are still disabled so - // removing the drag class cannot interpolate from the released gap. - void this._radioTunerNeedle?.offsetLeft; - this._radioTuner?.classList.remove('is-dragging'); - if (result && !result.ok) { - this._radioTunerBandPinnedForNavigation = false; - if (result.reason === 'station-unavailable') { - if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR'; - if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE'; - this._radioTunerSlider?.setAttribute( - 'aria-valuetext', - 'Station unavailable after directory refresh', - ); - } - } - }; - const cycleRadio = (direction, { rotate = true } = {}) => { - this._radioTunerBandPinnedForNavigation = true; - const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; - const cycled = radioLayer.cycleStation(direction, { - rotate, - stationIds: pool.map((station) => station.id), - origin: 'user', - }); - if (!cycled) { - this._radioTunerBandPinnedForNavigation = false; - return; - } - }; - const toggleRadio = async (trigger) => { - if (!this._dataManager?.layers?.has('radio')) return; - if (trigger.getAttribute('aria-busy') === 'true') return; - const enabling = !this._dataManager.isEnabled('radio'); - const revealAfterEnable = enabling && trigger === this._radioEnableBtn; - trigger.setAttribute('aria-disabled', 'true'); - trigger.setAttribute('aria-busy', 'true'); - try { - const toggled = await this._runUserFacingContextAction( - (notificationToken) => this._dataManager.setEnabled('radio', enabling, { - origin: 'user', - notificationToken, - }), - `Radio could not ${enabling ? 'start' : 'stop'} cleanly`, - ); - if (toggled === false) return; - if (enabling && trigger === this._radioEnableBtn - && !document.getElementById('global-context-panel')?.classList.contains('collapsed')) { - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - } - if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger); - } finally { - trigger.setAttribute('aria-disabled', 'false'); - trigger.setAttribute('aria-busy', 'false'); - if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true }); - } - }; - this._radioEnableBtn?.addEventListener('click', () => void toggleRadio(this._radioEnableBtn)); - this._contextRadioMiniEnableBtn?.addEventListener('click', () => void toggleRadio(this._contextRadioMiniEnableBtn)); - this._cockpitRadioEnableBtn?.addEventListener('click', () => void toggleRadio(this._cockpitRadioEnableBtn)); - this._contextRadioToggleBtn?.addEventListener('click', () => { - const contextPanel = document.getElementById('global-context-panel'); - if (contextPanel && !contextPanel.classList.contains('collapsed')) { - setRadioDisclosure(false); - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - void this._revealRadioPanelInsideContext({ - focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'), - }); - return; - } - setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open')); + this._radioControls?.destroy(); + this._radioControls = new RadioControls({ + elements: { + _cockpitDisplayPanel: this._cockpitDisplayPanel, + _cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn, + _cockpitRadioEnableBtn: this._cockpitRadioEnableBtn, + _cockpitRadioNextBtn: this._cockpitRadioNextBtn, + _cockpitRadioPanel: this._cockpitRadioPanel, + _cockpitRadioPlayBtn: this._cockpitRadioPlayBtn, + _cockpitRadioPrevBtn: this._cockpitRadioPrevBtn, + _cockpitRadioStation: this._cockpitRadioStation, + _cockpitRadioToggleBtn: this._cockpitRadioToggleBtn, + _cockpitRadioVolume: this._cockpitRadioVolume, + _cockpitRadioVolumeValue: this._cockpitRadioVolumeValue, + _cockpitUtilityControls: this._cockpitUtilityControls, + _contextRadioDetailsBtn: this._contextRadioDetailsBtn, + _contextRadioDock: this._contextRadioDock, + _contextRadioMini: this._contextRadioMini, + _contextRadioMiniCloseBtn: this._contextRadioMiniCloseBtn, + _contextRadioMiniEnableBtn: this._contextRadioMiniEnableBtn, + _contextRadioMiniNextBtn: this._contextRadioMiniNextBtn, + _contextRadioMiniPlayBtn: this._contextRadioMiniPlayBtn, + _contextRadioMiniPrevBtn: this._contextRadioMiniPrevBtn, + _contextRadioMiniStation: this._contextRadioMiniStation, + _contextRadioMiniVolume: this._contextRadioMiniVolume, + _contextRadioMiniVolumeValue: this._contextRadioMiniVolumeValue, + _contextRadioToggleBtn: this._contextRadioToggleBtn, + _radioEnableBtn: this._radioEnableBtn, + _radioFilter: this._radioFilter, + _radioLayerState: this._radioLayerState, + _radioNextBtn: this._radioNextBtn, + _radioPanel: this._radioPanel, + _radioPlayBtn: this._radioPlayBtn, + _radioPlaybackState: this._radioPlaybackState, + _radioPrevBtn: this._radioPrevBtn, + _radioStationHomepage: this._radioStationHomepage, + _radioStationMeta: this._radioStationMeta, + _radioStationName: this._radioStationName, + _radioStationTags: this._radioStationTags, + _radioStopBtn: this._radioStopBtn, + _radioTuner: this._radioTuner, + _radioTunerBandLabel: this._radioTunerBandLabel, + _radioTunerNeedle: this._radioTunerNeedle, + _radioTunerSlider: this._radioTunerSlider, + _radioTunerStation: this._radioTunerStation, + _radioTunerValue: this._radioTunerValue, + _radioVolume: this._radioVolume, + _radioVolumeValue: this._radioVolumeValue, + }, + radio: radioLayer, + canvas: this.viewer?.canvas, + actions: { + isRegistered: () => this._dataManager?.layers?.has('radio'), + isEnabled: () => this._dataManager?.isEnabled('radio'), + setEnabled: (enabled, options) => this._dataManager.setEnabled('radio', enabled, options), + setParams: (params, options) => this._dataManager?.setLayerParams('radio', params, options), + getLifecycle: () => this._dataManager?.getLayerLifecycleState?.('radio'), + runUserAction: (...args) => this._runUserFacingContextAction(...args), + setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + revealStyleParameters: () => this._revealCockpitStyleParameters(), + setSignalCollapsed: (value) => this.cockpitView?.setSignalCollapsed(value), + isCockpitActive: () => this.cockpitView?.active, + signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed, + layoutCockpit: () => this.cockpitView?.scheduleContextLayout(), + preservePanelStateDuringClear: () => this._preservePanelStateDuringLayerClear, + scheduleLayout: () => this._scheduleRightPanelLayout(), + }, }); - this._contextRadioMiniCloseBtn?.addEventListener('click', () => { - setRadioDisclosure(false, { returnFocus: true }); - }); - this._contextRadioDetailsBtn?.addEventListener('click', () => { - if (!this.cockpitView?.active) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - this.setPanelCollapsed('radio-panel', false, { explicit: true }); - setRadioDisclosure(false); - this._radioEnableBtn?.focus({ preventScroll: true }); - }); - this._cockpitRadioToggleBtn?.addEventListener('click', () => { - const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true'; - setCockpitDisclosure('radio', !open); - }); - document.addEventListener('pointerdown', (event) => { - if (!this._contextRadioDock?.classList.contains('disclosure-open')) return; - if (event.target?.closest?.('#context-radio-dock')) return; - setRadioDisclosure(false); - }, tunerListenerOptions); - document.addEventListener('pointerdown', (event) => { - if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return; - if (event.target?.closest?.('.cockpit-vision-controls')) return; - if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return; - setCockpitDisclosure('display', false); - setCockpitDisclosure('radio', false); - }, tunerListenerOptions); - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return; - event.preventDefault(); - // Immediate: a plain stopPropagation() still lets every LATER listener on - // this same document run, so closing the disclosure ALSO dismissed the - // first-run launcher — one key, two actions. Matches the cockpit - // disclosure handler directly below. - event.stopImmediatePropagation(); - const escapedFromDisclosure = event.target === this._contextRadioToggleBtn - || this._contextRadioToggleBtn?.contains?.(event.target); - setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure }); - if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.(); - }, { capture: true, signal: this._radioTunerAbort.signal }); - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape') return; - const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true'; - const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true'; - if (!displayOpen && !radioOpen) return; - const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)'); - if (nestedPanel) return; - event.preventDefault(); - event.stopImmediatePropagation(); - const kind = displayOpen ? 'display' : 'radio'; - const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn; - const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); - setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure }); - if (escapedFromDisclosure) disclosure?.blur?.(); - }, { capture: true, signal: this._radioTunerAbort.signal }); - window.addEventListener('gev:cockpit-mode-changed', (event) => { - if (event?.detail?.active) return; - setCockpitDisclosure('display', false); - setCockpitDisclosure('radio', false); - }, tunerListenerOptions); - window.addEventListener('gev:cockpit-signal-expanded', () => { - setCockpitDisclosure('display', false); - }, tunerListenerOptions); - window.addEventListener('gev:cockpit-context-expanded', () => { - this.setPanelCollapsed('data-panel', true); - }, tunerListenerOptions); - this._radioFilter?.addEventListener('change', () => { - const presentation = radioLayer.getUIState(); - if (!presentation.presentationActive) { - this._radioFilter.value = presentation.filter; - return; - } - if (this._radioTunerDragging) finishTuner(false); - if (!this._dataManager?.setLayerParams('radio', { - filter: this._radioFilter.value, - }, { origin: 'user' })) { - this._radioFilter.value = radioLayer.getUIState().filter; - return; - } - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerPool = []; - refreshTunerBand({ force: true }); - }); - this._radioPrevBtn?.addEventListener('click', () => cycleRadio(-1)); - this._radioNextBtn?.addEventListener('click', () => cycleRadio(1)); - this._radioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - this._radioStopBtn?.addEventListener('click', () => radioLayer.stopPlayback({ origin: 'user' })); - this._radioVolume?.addEventListener('input', () => { - const value = Number(this._radioVolume.value); - if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - this._contextRadioMiniPrevBtn?.addEventListener('click', () => cycleRadio(-1)); - this._contextRadioMiniNextBtn?.addEventListener('click', () => cycleRadio(1)); - this._contextRadioMiniPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - // Cockpit owns the Cesium camera even though it intentionally clears - // viewer.trackedEntity. Station changes must never start the map-view - // rotation/fallback flights that would compete with its preUpdate pose. - this._cockpitRadioPrevBtn?.addEventListener('click', () => cycleRadio(-1, { rotate: false })); - this._cockpitRadioNextBtn?.addEventListener('click', () => cycleRadio(1, { rotate: false })); - this._cockpitRadioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); - this._contextRadioMiniVolume?.addEventListener('input', () => { - const value = Number(this._contextRadioMiniVolume.value); - if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - this._cockpitRadioVolume?.addEventListener('input', () => { - const value = Number(this._cockpitRadioVolume.value); - if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`; - this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); - }); - const updateTunerFromPointer = (event) => { - const rect = this._radioTunerSlider?.getBoundingClientRect(); - if (!rect || !this._radioTunerStations.length) return false; - const position = radioTunerPointerPosition( - event.clientX, - rect.left, - rect.width, - this._radioTunerStations.length, - ); - if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = position.stationIndex; - this._radioTunerCoordinate = position.coordinate; - this._radioTunerSlider.value = String(position.stationIndex); - tunerPreview({ coordinate: position.coordinate }); - return true; - }; - this._radioTunerSlider?.addEventListener('pointerdown', (event) => { - if (!beginTuner()) return; - this._radioTunerPointerId = event.pointerId; - this._radioTunerSlider.focus({ preventScroll: true }); - try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ } - updateTunerFromPointer(event); - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointermove', (event) => { - if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return; - updateTunerFromPointer(event); - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('input', () => { - if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return; - if (!this._radioTunerDragging && !beginTuner()) return; - const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length); - if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = inputSlot.slot; - this._radioTunerCoordinate = inputSlot.stationIndex; - tunerPreview({ coordinate: this._radioTunerCoordinate }); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('change', () => { - if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointerup', (event) => { - if (this._radioTunerPointerId !== event.pointerId) return; - updateTunerFromPointer(event); - finishTuner(true); - try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } - event.preventDefault(); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('pointercancel', (event) => { - if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return; - try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } - finishTuner(false); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('lostpointercapture', (event) => { - if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && this._radioTunerDragging) { - event.preventDefault(); - finishTuner(false); - return; - } - if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return; - if (!this._radioTunerDragging && !beginTuner()) return; - event.preventDefault(); - this._radioTunerKeyboardKey = event.key; - const max = Math.max(0, this._radioTunerStations.length - 1); - const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot; - const page = Math.max(1, Math.round(max / 10)); - const next = event.key === 'Home' ? 0 - : event.key === 'End' ? max - : event.key === 'PageUp' ? current + page - : event.key === 'PageDown' ? current - page - : current + (event.key === 'ArrowRight' ? 1 : -1); - const slot = radioTunerSlot(next, this._radioTunerStations.length); - if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = slot.slot; - this._radioTunerCoordinate = slot.stationIndex; - this._radioTunerSlider.value = String(slot.slot); - tunerPreview({ coordinate: this._radioTunerCoordinate }); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('keyup', (event) => { - if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return; - event.preventDefault(); - finishTuner(true); - }, tunerListenerOptions); - this._radioTunerSlider?.addEventListener('blur', () => finishTuner(true), tunerListenerOptions); - const releaseNavigationBand = () => { - this._radioTunerBandPinnedForNavigation = false; - }; - this.viewer?.canvas?.addEventListener('pointerdown', releaseNavigationBand, tunerListenerOptions); - this.viewer?.canvas?.addEventListener('wheel', releaseNavigationBand, tunerListenerOptions); - // The directory order is catalog/filter authority, not camera authority. - // Globe motion therefore never rebuilds or re-ranks the frequency band. - this._radioTunerCameraRemove?.(); - this._radioTunerCameraRemove = null; - this._radioSelectedHandler = () => this.setPanelCollapsed('radio-panel', false); - document.addEventListener('gev:radio-selected', this._radioSelectedHandler); } - /** - * Reveal the newly enabled directory and transport inside Context without - * moving focus, the page, or the globe. Only the expanded Enable path calls - * this helper. - * @param {HTMLElement} trigger Initiating Radio Enable button. - * @returns {Promise} Whether the internal scroller moved. - */ - async _revealRadioControlsAfterExplicitEnable(trigger) { - const contextPanel = document.getElementById('global-context-panel'); - const scroller = contextPanel?.querySelector('.global-context-panel-inner'); - const directory = this._radioPanel?.querySelector('.radio-directory-row'); - const transport = this._radioPanel?.querySelector('.radio-transport'); - if (!contextPanel || contextPanel.classList.contains('collapsed') - || !scroller || !directory || !transport || !this._radioState?.enabled) return false; + _setCockpitDisclosure(...args) { return this._radioControls?._setCockpitDisclosure?.(...args); } - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (!this._radioState?.enabled || !trigger?.isConnected) return false; + _setRadioDisclosure(...args) { return this._radioControls?._setRadioDisclosure?.(...args); } - const viewport = scroller.getBoundingClientRect(); - const directoryRect = directory.getBoundingClientRect(); - const transportRect = transport.getBoundingClientRect(); - const margin = 10; - const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); - const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin); - const desired = minimum <= maximum - ? Math.min(Math.max(scroller.scrollTop, minimum), maximum) - : minimum; - const next = Math.min( - Math.max(0, scroller.scrollHeight - scroller.clientHeight), - Math.max(0, desired), - ); - if (Math.abs(next - scroller.scrollTop) < 1) return false; - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); - return true; - } + _syncContextRadioLauncherState(...args) { return this._radioControls?._syncContextRadioLauncherState?.(...args); } - /** - * Bring the embedded Radio section into the expanded Context scroller. - * This never changes Radio power, playback, selection, or Context mode. - * @param {{focusTarget?: HTMLElement|null}} [options] - * @returns {Promise} Whether the internal scroller moved. - */ - async _revealRadioPanelInsideContext({ focusTarget = null } = {}) { - const contextPanel = document.getElementById('global-context-panel'); - const scroller = contextPanel?.querySelector('.global-context-panel-inner'); - if (!contextPanel || contextPanel.classList.contains('collapsed') - || !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false; - - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false; - - const viewport = scroller.getBoundingClientRect(); - const radioRect = this._radioPanel.getBoundingClientRect(); - const desired = scroller.scrollTop + radioRect.top - viewport.top - 10; - const next = Math.min( - Math.max(0, scroller.scrollHeight - scroller.clientHeight), - Math.max(0, desired), - ); - const moved = Math.abs(next - scroller.scrollTop) >= 1; - if (moved) { - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); - } - focusTarget?.focus?.({ preventScroll: true }); - return moved; - } - - /** Keep the Context header Radio shortcut truthful for its current route. */ - _syncContextRadioLauncherState() { - if (!this._contextRadioToggleBtn) return; - const contextPanel = document.getElementById('global-context-panel'); - const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed')); - if (contextExpanded) { - const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed')); - this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded)); - const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context'; - this._contextRadioToggleBtn.setAttribute('aria-label', label); - this._contextRadioToggleBtn.title = label; - return; - } - const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open')); - this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen)); - const action = compactOpen ? 'Close' : 'Open'; - this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`); - this._contextRadioToggleBtn.title = `${action} compact Radio controls`; - } - - /** Render Radio state without making playback or Context decisions. */ - _renderRadioState(state) { - if (!state || !this._radioPanel) return; - const lifecycle = this._dataManager?.getLayerLifecycleState?.('radio') || null; - const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled'); - state = { - ...state, - enabled: lifecycle ? lifecycle.enabled : state.enabled, - lifecycleState, - lifecycleUncertain: lifecycle?.uncertain || false, - }; - this._radioState = state; - const enabled = Boolean(state.enabled); - const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling'; - const uncertain = Boolean(state.lifecycleUncertain); - const interactive = enabled && !transitioning && !uncertain; - const selected = state.selected || null; - const hasStations = state.filteredCount > 0; - const activePlayback = ['playing', 'buffering'].includes(state.audioState); - document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); - this._radioPanel.classList.toggle('radio-enabled', enabled); - this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain); - this._contextRadioDock?.classList.toggle('active', enabled); - if (this._contextRadioToggleBtn) { - this._contextRadioToggleBtn.classList.toggle('active', enabled); - } - this._syncContextRadioLauncherState(); - this._radioLayerState?.classList.toggle('active', enabled); - if (this._radioLayerState) { - this._radioLayerState.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF'))); - } - if (this._radioEnableBtn) { - this._radioEnableBtn.classList.toggle('active', enabled); - this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._radioEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._radioEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._radioEnableBtn.disabled = false; - this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._radioEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - if (this._contextRadioMiniEnableBtn) { - this._contextRadioMiniEnableBtn.classList.toggle('active', enabled); - this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._contextRadioMiniEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._contextRadioMiniEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._contextRadioMiniEnableBtn.disabled = false; - this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - if (this._cockpitRadioEnableBtn) { - this._cockpitRadioEnableBtn.classList.toggle('active', enabled); - this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._cockpitRadioEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._cockpitRadioEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._cockpitRadioEnableBtn.disabled = false; - this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - - if (this._radioFilter) { - const prior = state.filter || 'all'; - const categorySignature = state.categories - .map((category) => `${category.id}:${category.count}:${category.color}`) - .join('|'); - if (categorySignature !== this._radioCategorySignature) { - this._radioFilter.replaceChildren(...state.categories.map((category) => { - const option = document.createElement('option'); - option.value = category.id; - option.textContent = `● ${category.label} (${category.count})`; - option.dataset.radioColor = category.color; - option.style.color = category.color; - option.setAttribute('aria-label', `${category.label} (${category.count})`); - return option; - })); - this._radioCategorySignature = categorySignature; - } - this._radioFilter.value = prior; - const activeCategory = state.categories.find((category) => category.id === prior); - this._radioFilter.style.color = activeCategory?.color || ''; - this._radioFilter.disabled = !interactive || !state.stationCount; - } - - const tunerAvailable = interactive && state.filteredCount > 0; - if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; - if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; - if (this._radioTunerBandLabel) { - const activeCategory = state.categories.find((category) => category.id === state.filter); - this._radioTunerBandLabel.textContent = state.filter === 'all' - ? 'DIRECTORY BAND' - : `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`; - } - this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic)); - if (tunerAvailable) this._refreshRadioTunerBand?.(); - if (!tunerAvailable && this._radioTunerDragging) { - this._radioTunerDragging = false; - this._radioTunerDragSnapshot = null; - this._radioTunerStations = []; - this._radioTuner?.classList.remove('is-static', 'is-dragging'); - } - if (!tunerAvailable) { - this._radioTunerStations = []; - this._radioTunerPool = []; - this._radioTunerBandSignature = ''; - this._radioTunerSelectedId = null; - } - - if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED'; - if (this._radioStationMeta) { - const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : ''; - const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : ''; - this._radioStationMeta.textContent = selected - ? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only' - : (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.'); - } - if (this._radioStationTags) { - const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; - this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : ''; - } - if (this._radioStationHomepage) { - const homepage = selected?.homepage || ''; - this._radioStationHomepage.hidden = !homepage; - if (homepage) this._radioStationHomepage.href = homepage; - else this._radioStationHomepage.removeAttribute('href'); - } - - if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations; - if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations; - if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations; - if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations; - if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations; - if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; - if (this._radioPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._radioPlayBtn.disabled = !interactive || !hasStations; - this._radioPlayBtn.classList.toggle('active', activePlayback); - this._radioPlayBtn.textContent = action.toUpperCase(); - this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - } - if (this._contextRadioMiniPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations; - this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback); - this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; - this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - this._contextRadioMiniPlayBtn.title = action; - } - if (this._cockpitRadioPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations; - this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback); - this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; - this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - this._cockpitRadioPlayBtn.title = action; - } - if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped'; - if (this._radioVolume) this._radioVolume.disabled = !interactive; - if (this._radioVolume && document.activeElement !== this._radioVolume) { - this._radioVolume.value = String(Math.round(state.volume * 100)); - if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) { - this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100)); - } - if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive; - if (this._contextRadioMiniVolumeValue) { - this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) { - this._cockpitRadioVolume.value = String(Math.round(state.volume * 100)); - } - if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive; - if (this._cockpitRadioVolumeValue) { - this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._contextRadioMiniStation) { - this._contextRadioMiniStation.textContent = uncertain - ? 'RADIO STATE UNCERTAIN' - : (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY')); - } - if (this._cockpitRadioStation) { - this._cockpitRadioStation.textContent = uncertain - ? 'UNCERTAIN' - : (selected?.name || (state.loading ? 'SYNCING' : 'READY')); - } - if (this._radioPlaybackState) { - const catalogSuffix = state.degraded - ? (state.stale ? ' · stale/degraded directory' : ' · degraded directory') - : (state.stale ? ' · stale directory' : ''); - const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : ''; - const messages = { - stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off', - loading: 'Connecting directly to broadcaster…', - buffering: 'Buffering broadcaster stream…', - playing: `Playing ${selected?.name || 'station'}`, - paused: `Paused ${selected?.name || 'station'}`, - error: state.audioError || 'Broadcaster stream unavailable', - }; - const voiceSuffix = state.voiceDucked - ? ' · muted during voice interaction' - : (state.voiceRestoring ? ' · restoring volume after voice' : ''); - const tuningSuffix = state.tuningAwaitingStationId - ? (state.audioState === 'error' - ? ' · static indicates no broadcaster audio' - : ' · tuning static until broadcaster starts') - : ''; - const unavailable = state.tuningUnavailableStationId - ? 'Station unavailable after directory refresh — choose another channel' - : null; - const lifecycleMessage = transitioning - ? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…') - : null; - const uncertainMessage = uncertain - ? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile' - : null; - this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; - this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error')); - } - if ( - !enabled - && !transitioning - && !this._preservePanelStateDuringLayerClear - && !this._radioPanel.classList.contains('collapsed') - ) { - this.setPanelCollapsed('radio-panel', true); - } - this._scheduleRightPanelLayout(); - } + _renderRadioState(...args) { return this._radioControls?._renderRadioState?.(...args); } /** * Activates an explicit CCTV target, then releases tracking before its camera @@ -9282,6 +8508,7 @@ export class StyleManager { this._mapSourceControls?.destroy(); this._clearLayersControl?.destroy(); this._locationControls?.destroy(); + this._radioControls?.destroy(); this._visualEffects.stop(); this._styleParameters?.destroy(); for (const control of this._panelDisclosureControls || []) control.destroy(); @@ -9419,22 +8646,7 @@ export class StyleManager { window.removeEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler); this._leftStackCockpitModeHandler = null; } - this._radioUnsubscribe?.(); - this._radioUnsubscribe = null; - this._radioTunerAbort?.abort(); - this._radioTunerAbort = null; - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerDragSnapshot = null; - this._radioTunerPool = []; - this._radioTunerCameraRemove?.(); - this._radioTunerCameraRemove = null; - this._refreshRadioTunerBand = null; - document.getElementById('title-bar')?.classList.remove('radio-broadcasting'); - radioLayer.endTuning(); - if (this._radioSelectedHandler) { - document.removeEventListener('gev:radio-selected', this._radioSelectedHandler); - this._radioSelectedHandler = null; - } + destroyTrackedReadout(); destroyDetection(); destroyWorldOverlay(); diff --git a/src/ui/radio.js b/src/ui/radio.js new file mode 100644 index 000000000..496c23444 --- /dev/null +++ b/src/ui/radio.js @@ -0,0 +1,2 @@ +export { RadioControls } from './radioControls.js'; +export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js new file mode 100644 index 000000000..ef1af2d41 --- /dev/null +++ b/src/ui/radioBindings.js @@ -0,0 +1,488 @@ +import { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; + +/** Bind Radio input using a RadioControls receiver and supplied actions. */ +export function bindRadioControls() { + if (!this._radioPanel) return; + const tunerListenerOptions = {}; + const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { + const open = Boolean(expanded); + this._contextRadioDock?.classList.toggle('disclosure-open', open); + if (this._contextRadioMini) this._contextRadioMini.hidden = !open; + this._syncContextRadioLauncherState(); + if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true }); + }; + this._setRadioDisclosure = setRadioDisclosure; + const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => { + const displayOpen = kind === 'display' && Boolean(expanded); + const radioOpen = kind === 'radio' && Boolean(expanded); + if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true); + if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen; + if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen; + this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control') + ?.classList.toggle('is-expanded', displayOpen); + this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control') + ?.classList.toggle('is-expanded', radioOpen); + this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen)); + this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen)); + if (displayOpen) this.actions.revealStyleParameters(); + if (this._cockpitDisplayToggleBtn) { + const action = displayOpen ? 'Collapse' : 'Expand'; + this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀'; + this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`); + this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`; + } + if (this._cockpitRadioToggleBtn) { + const action = radioOpen ? 'Collapse' : 'Expand'; + this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀'; + this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`); + this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`; + } + if (!expanded && returnFocus) { + (kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn) + ?.focus({ preventScroll: true }); + } + if (!displayOpen && !radioOpen + && this.actions.isCockpitActive() + && !this.actions.signalUserCollapsed()) { + this.actions.setSignalCollapsed(false); + } + this.actions.layoutCockpit(); + }; + this._setCockpitDisclosure = setCockpitDisclosure; + const syncTunerTape = (coordinate) => { + const scale = this._radioTuner?.querySelector('.radio-tuner-scale'); + const dial = this._radioTuner?.querySelector('.radio-tuner-dial'); + if (!scale || !dial) return null; + const model = buildRadioTunerTicks( + coordinate, + this._radioTunerStations.length, + dial.getBoundingClientRect().width, + ); + while (scale.children.length < model.ticks.length) { + const tick = document.createElement('span'); + tick.className = 'radio-tuner-tick'; + scale.append(tick); + } + while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove(); + model.ticks.forEach((entry, index) => { + const tick = scale.children[index]; + tick.style.left = `${entry.xPx}px`; + tick.textContent = entry.label; + tick.dataset.stationIndex = String(entry.stationIndex); + tick.classList.toggle('is-current', entry.current); + }); + scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); + return model; + }; + const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => { + const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length); + const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null; + const resolvedCoordinate = this._radioTunerStations.length <= 1 + ? 0 + : Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0)); + const ratio = this._radioTunerStations.length === 1 + ? 0.5 + : resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1); + this._radioTunerCoordinate = resolvedCoordinate; + this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); + this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic)); + syncTunerTape(resolvedCoordinate); + if (this._radioTunerValue) { + this._radioTunerValue.textContent = station + ? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` + : 'NO STATIONS'; + } + if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE'; + if (this._radioTunerSlider) { + this._radioTunerSlider.setAttribute('aria-valuetext', station + ? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}` + : 'No station available'); + } + if (syncStatic) this.radio.previewTuningStation(station?.id || null, { rotate }); + return station; + }; + const setTunerDirectory = (pool) => { + this._radioTunerPool = [...pool]; + this._radioTunerStations = [...pool]; + this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|'); + if (this._radioTunerSlider) { + this._radioTunerSlider.min = '0'; + this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1)); + this._radioTunerSlider.step = '1'; + } + }; + const refreshTunerBand = ({ force = false } = {}) => { + if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false; + const selectedId = this._radioState?.selected?.id || null; + const pool = this.radio.getTunerStations(750); + const poolSignature = pool.map((station) => station.id).join('|'); + const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|'); + if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false; + setTunerDirectory(pool); + this._radioTunerSelectedId = selectedId; + const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId); + const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length); + this._radioTunerSlider.value = String(slot.slot); + this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; + tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); + return true; + }; + this._refreshRadioTunerBand = refreshTunerBand; + const beginTuner = () => { + if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false; + refreshTunerBand(); + if (!this._radioTunerStations.length || !this.radio.beginTuning()) return false; + // A tuner-owned camera preview must never replace the frozen directory. + // Only an explicit globe pointer/wheel gesture releases camera pinning. + this._radioTunerBandPinnedForNavigation = true; + this._radioTunerDragging = true; + this._radioTuner?.classList.add('is-dragging'); + const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id); + const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length); + this._radioTunerSlider.value = String(slot.slot); + this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; + this._radioTunerDragStartSlot = slot.slot; + this._radioTunerDragSnapshot = { + stations: [...this._radioTunerStations], + bandSignature: this._radioTunerBandSignature, + selectedId: this._radioTunerSelectedId, + slot: slot.slot, + coordinate: this._radioTunerCoordinate, + }; + this._radioTunerLastSlot = slot.slot; + this._radioTunerDragDirection = 0; + tunerPreview({ coordinate: this._radioTunerCoordinate }); + return true; + }; + const finishTuner = (commit) => { + if (!this._radioTunerDragging) return; + const dragSnapshot = this._radioTunerDragSnapshot; + if (!commit && dragSnapshot) { + this._radioTunerStations = [...dragSnapshot.stations]; + this._radioTunerPool = [...dragSnapshot.stations]; + this._radioTunerBandSignature = dragSnapshot.bandSignature; + this._radioTunerSelectedId = dragSnapshot.selectedId; + if (this._radioTunerSlider) { + const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length); + this._radioTunerSlider.max = String(startSlot.max); + this._radioTunerSlider.value = String(startSlot.slot); + } + this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate) + ? dragSnapshot.coordinate + : dragSnapshot.slot; + } else if (!commit && this._radioTunerSlider) { + this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); + this._radioTunerCoordinate = this._radioTunerDragStartSlot; + } + let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit }); + if (commit && !station && this._radioTunerSlider) { + const snapped = radioTunerCommitSlot( + this._radioTunerSlider.value, + this._radioTunerStations.length, + ); + this._radioTunerSlider.value = String(snapped.slot); + this._radioTunerCoordinate = snapped.stationIndex; + station = tunerPreview({ coordinate: this._radioTunerCoordinate }); + } + let result = null; + if (commit && station) { + // Keep the exact band used by the drag so the selected channel cannot + // jump to a refreshed catalog slot while its camera flight settles. + this._radioTunerBandPinnedForNavigation = true; + result = this.radio.commitTuningStation(station.id, { origin: 'user' }); + } else if (!commit) { + this.radio.cancelTuning(); + } else { + this.radio.endTuning(); + } + // Radio emits selection/tuning state synchronously. Keep both the logical + // drag and the no-transition class active until that state has settled, + // then restore the exact selected slot before permitting CSS motion. + this._radioTunerDragging = false; + this._radioTunerPointerId = null; + this._radioTunerKeyboardKey = null; + if (commit && (!result || result.ok)) refreshTunerBand(); + this._radioTunerDragSnapshot = null; + // Flush the snapped position while transitions are still disabled so + // removing the drag class cannot interpolate from the released gap. + void this._radioTunerNeedle?.offsetLeft; + this._radioTuner?.classList.remove('is-dragging'); + if (result && !result.ok) { + this._radioTunerBandPinnedForNavigation = false; + if (result.reason === 'station-unavailable') { + if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR'; + if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE'; + this._radioTunerSlider?.setAttribute( + 'aria-valuetext', + 'Station unavailable after directory refresh', + ); + } + } + }; + const cycleRadio = (direction, { rotate = true } = {}) => { + this._radioTunerBandPinnedForNavigation = true; + const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; + const cycled = this.radio.cycleStation(direction, { + rotate, + stationIds: pool.map((station) => station.id), + origin: 'user', + }); + if (!cycled) { + this._radioTunerBandPinnedForNavigation = false; + return; + } + }; + const toggleRadio = async (trigger) => { + if (!this.actions.isRegistered()) return; + if (trigger.getAttribute('aria-busy') === 'true') return; + const enabling = !this.actions.isEnabled(); + const revealAfterEnable = enabling && trigger === this._radioEnableBtn; + trigger.setAttribute('aria-disabled', 'true'); + trigger.setAttribute('aria-busy', 'true'); + try { + const toggled = await this.actions.runUserAction( + (notificationToken) => this.actions.setEnabled(enabling, { + origin: 'user', + notificationToken, + }), + `Radio could not ${enabling ? 'start' : 'stop'} cleanly`, + ); + if (this.destroyed || toggled === false) return; + if (enabling && trigger === this._radioEnableBtn + && !document.getElementById('global-context-panel')?.classList.contains('collapsed')) { + this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); + } + if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger); + } finally { + if (!this.destroyed) { + trigger.setAttribute('aria-disabled', 'false'); + trigger.setAttribute('aria-busy', 'false'); + if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true }); + } + } + }; + this.listen(this._radioEnableBtn, 'click', () => void toggleRadio(this._radioEnableBtn)); + this.listen(this._contextRadioMiniEnableBtn, 'click', () => void toggleRadio(this._contextRadioMiniEnableBtn)); + this.listen(this._cockpitRadioEnableBtn, 'click', () => void toggleRadio(this._cockpitRadioEnableBtn)); + this.listen(this._contextRadioToggleBtn, 'click', () => { + const contextPanel = document.getElementById('global-context-panel'); + if (contextPanel && !contextPanel.classList.contains('collapsed')) { + setRadioDisclosure(false); + this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); + void this._revealRadioPanelInsideContext({ + focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'), + }); + return; + } + setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open')); + }); + this.listen(this._contextRadioMiniCloseBtn, 'click', () => { + setRadioDisclosure(false, { returnFocus: true }); + }); + this.listen(this._contextRadioDetailsBtn, 'click', () => { + if (!this.actions.isCockpitActive()) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); + this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); + setRadioDisclosure(false); + this._radioEnableBtn?.focus({ preventScroll: true }); + }); + this.listen(this._cockpitRadioToggleBtn, 'click', () => { + const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true'; + setCockpitDisclosure('radio', !open); + }); + this.listen(document, 'pointerdown', (event) => { + if (!this._contextRadioDock?.classList.contains('disclosure-open')) return; + if (event.target?.closest?.('#context-radio-dock')) return; + setRadioDisclosure(false); + }, tunerListenerOptions); + this.listen(document, 'pointerdown', (event) => { + if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return; + if (event.target?.closest?.('.cockpit-vision-controls')) return; + if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return; + setCockpitDisclosure('display', false); + setCockpitDisclosure('radio', false); + }, tunerListenerOptions); + this.listen(document, 'keydown', (event) => { + if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return; + event.preventDefault(); + // Immediate: a plain stopPropagation() still lets every LATER listener on + // this same document run, so closing the disclosure ALSO dismissed the + // first-run launcher — one key, two actions. Matches the cockpit + // disclosure handler directly below. + event.stopImmediatePropagation(); + const escapedFromDisclosure = event.target === this._contextRadioToggleBtn + || this._contextRadioToggleBtn?.contains?.(event.target); + setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure }); + if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.(); + }, { capture: true, }); + this.listen(document, 'keydown', (event) => { + if (event.key !== 'Escape') return; + const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true'; + const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true'; + if (!displayOpen && !radioOpen) return; + const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)'); + if (nestedPanel) return; + event.preventDefault(); + event.stopImmediatePropagation(); + const kind = displayOpen ? 'display' : 'radio'; + const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn; + const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); + setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure }); + if (escapedFromDisclosure) disclosure?.blur?.(); + }, { capture: true, }); + this.listen(window, 'gev:cockpit-mode-changed', (event) => { + if (event?.detail?.active) return; + setCockpitDisclosure('display', false); + setCockpitDisclosure('radio', false); + }, tunerListenerOptions); + this.listen(window, 'gev:cockpit-signal-expanded', () => { + setCockpitDisclosure('display', false); + }, tunerListenerOptions); + this.listen(window, 'gev:cockpit-context-expanded', () => { + this.actions.setPanelCollapsed('data-panel', true); + }, tunerListenerOptions); + this.listen(this._radioFilter, 'change', () => { + const presentation = this.radio.getUIState(); + if (!presentation.presentationActive) { + this._radioFilter.value = presentation.filter; + return; + } + if (this._radioTunerDragging) finishTuner(false); + if (!this.actions.setParams({ + filter: this._radioFilter.value, + }, { origin: 'user' })) { + this._radioFilter.value = this.radio.getUIState().filter; + return; + } + this._radioTunerBandPinnedForNavigation = false; + this._radioTunerPool = []; + refreshTunerBand({ force: true }); + }); + this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1)); + this.listen(this._radioNextBtn, 'click', () => cycleRadio(1)); + this.listen(this._radioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); + this.listen(this._radioStopBtn, 'click', () => this.radio.stopPlayback({ origin: 'user' })); + this.listen(this._radioVolume, 'input', () => { + const value = Number(this._radioVolume.value); + if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`; + this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); + }); + this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1)); + this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1)); + this.listen(this._contextRadioMiniPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); + // Cockpit owns the Cesium camera even though it intentionally clears + // viewer.trackedEntity. Station changes must never start the map-view + // rotation/fallback flights that would compete with its preUpdate pose. + this.listen(this._cockpitRadioPrevBtn, 'click', () => cycleRadio(-1, { rotate: false })); + this.listen(this._cockpitRadioNextBtn, 'click', () => cycleRadio(1, { rotate: false })); + this.listen(this._cockpitRadioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); + this.listen(this._contextRadioMiniVolume, 'input', () => { + const value = Number(this._contextRadioMiniVolume.value); + if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`; + this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); + }); + this.listen(this._cockpitRadioVolume, 'input', () => { + const value = Number(this._cockpitRadioVolume.value); + if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`; + this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); + }); + const updateTunerFromPointer = (event) => { + const rect = this._radioTunerSlider?.getBoundingClientRect(); + if (!rect || !this._radioTunerStations.length) return false; + const position = radioTunerPointerPosition( + event.clientX, + rect.left, + rect.width, + this._radioTunerStations.length, + ); + if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; + else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; + this._radioTunerLastSlot = position.stationIndex; + this._radioTunerCoordinate = position.coordinate; + this._radioTunerSlider.value = String(position.stationIndex); + tunerPreview({ coordinate: position.coordinate }); + return true; + }; + this.listen(this._radioTunerSlider, 'pointerdown', (event) => { + if (!beginTuner()) return; + this._radioTunerPointerId = event.pointerId; + this._radioTunerSlider.focus({ preventScroll: true }); + try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ } + updateTunerFromPointer(event); + event.preventDefault(); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'pointermove', (event) => { + if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return; + updateTunerFromPointer(event); + event.preventDefault(); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'input', () => { + if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return; + if (!this._radioTunerDragging && !beginTuner()) return; + const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length); + if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; + else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; + this._radioTunerLastSlot = inputSlot.slot; + this._radioTunerCoordinate = inputSlot.stationIndex; + tunerPreview({ coordinate: this._radioTunerCoordinate }); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'change', () => { + if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'pointerup', (event) => { + if (this._radioTunerPointerId !== event.pointerId) return; + updateTunerFromPointer(event); + finishTuner(true); + try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } + event.preventDefault(); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'pointercancel', (event) => { + if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return; + try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } + finishTuner(false); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'lostpointercapture', (event) => { + if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'keydown', (event) => { + if (event.key === 'Escape' && this._radioTunerDragging) { + event.preventDefault(); + finishTuner(false); + return; + } + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return; + if (!this._radioTunerDragging && !beginTuner()) return; + event.preventDefault(); + this._radioTunerKeyboardKey = event.key; + const max = Math.max(0, this._radioTunerStations.length - 1); + const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot; + const page = Math.max(1, Math.round(max / 10)); + const next = event.key === 'Home' ? 0 + : event.key === 'End' ? max + : event.key === 'PageUp' ? current + page + : event.key === 'PageDown' ? current - page + : current + (event.key === 'ArrowRight' ? 1 : -1); + const slot = radioTunerSlot(next, this._radioTunerStations.length); + if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; + else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; + this._radioTunerLastSlot = slot.slot; + this._radioTunerCoordinate = slot.stationIndex; + this._radioTunerSlider.value = String(slot.slot); + tunerPreview({ coordinate: this._radioTunerCoordinate }); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'keyup', (event) => { + if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return; + event.preventDefault(); + finishTuner(true); + }, tunerListenerOptions); + this.listen(this._radioTunerSlider, 'blur', () => finishTuner(true), tunerListenerOptions); + const releaseNavigationBand = () => { + this._radioTunerBandPinnedForNavigation = false; + }; + this.listen(this.canvas, 'pointerdown', releaseNavigationBand, tunerListenerOptions); + this.listen(this.canvas, 'wheel', releaseNavigationBand, tunerListenerOptions); + // The directory order is catalog/filter authority, not camera authority. + // Globe motion therefore never rebuilds or re-ranks the frequency band. + this._radioSelectedHandler = () => this.actions.setPanelCollapsed('radio-panel', false); + this.listen(document, 'gev:radio-selected', this._radioSelectedHandler); + } + diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js new file mode 100644 index 000000000..a0379509f --- /dev/null +++ b/src/ui/radioControls.js @@ -0,0 +1,156 @@ +import { bindRadioControls } from './radioBindings.js'; +import { renderRadioState } from './radioPresentation.js'; + +/** Own Radio DOM, tuner interaction and subscription; receive playback and application actions. */ +export class RadioControls { + constructor({ elements, radio, actions, canvas }) { + Object.assign(this, elements); + this.radio = radio; + this.actions = actions; + this.canvas = canvas; + this.destroyed = false; + this.listeners = new AbortController(); + this._radioUnsubscribe = null; + this._radioState = null; + this._radioCategorySignature = ''; + this._radioTunerStations = []; + this._radioTunerPool = []; + this._radioTunerDragging = false; + this._radioTunerDragStartSlot = 0; + this._radioTunerDragSnapshot = null; + this._radioTunerLastSlot = 0; + this._radioTunerDragDirection = 0; + this._radioTunerCoordinate = 0; + this._radioTunerPointerId = null; + this._radioTunerKeyboardKey = null; + this._radioTunerBandSignature = ''; + this._radioTunerSelectedId = null; + this._radioTunerBandPinnedForNavigation = false; + this._refreshRadioTunerBand = null; + bindRadioControls.call(this); + } + + listen(target, type, handler, options = {}) { + target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal }); + } + + connect() { + this._radioUnsubscribe?.(); + this._radioUnsubscribe = null; + if (this.destroyed) return; + this._radioUnsubscribe = this.radio.subscribe?.((state) => this._renderRadioState(state)); + } + + _renderRadioState(state) { renderRadioState.call(this, state); } + + /** + * Reveal the newly enabled directory and transport inside Context without + * moving focus, the page, or the globe. Only the expanded Enable path calls + * this helper. + * @param {HTMLElement} trigger Initiating Radio Enable button. + * @returns {Promise} Whether the internal scroller moved. + */ + async _revealRadioControlsAfterExplicitEnable(trigger) { + const contextPanel = document.getElementById('global-context-panel'); + const scroller = contextPanel?.querySelector('.global-context-panel-inner'); + const directory = this._radioPanel?.querySelector('.radio-directory-row'); + const transport = this._radioPanel?.querySelector('.radio-transport'); + if (!contextPanel || contextPanel.classList.contains('collapsed') + || !scroller || !directory || !transport || !this._radioState?.enabled) return false; + + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected) return false; + + const viewport = scroller.getBoundingClientRect(); + const directoryRect = directory.getBoundingClientRect(); + const transportRect = transport.getBoundingClientRect(); + const margin = 10; + const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); + const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin); + const desired = minimum <= maximum + ? Math.min(Math.max(scroller.scrollTop, minimum), maximum) + : minimum; + const next = Math.min( + Math.max(0, scroller.scrollHeight - scroller.clientHeight), + Math.max(0, desired), + ); + if (Math.abs(next - scroller.scrollTop) < 1) return false; + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; + scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); + return true; + } + + /** + * Bring the embedded Radio section into the expanded Context scroller. + * This never changes Radio power, playback, selection, or Context mode. + * @param {{focusTarget?: HTMLElement|null}} [options] + * @returns {Promise} Whether the internal scroller moved. + */ + async _revealRadioPanelInsideContext({ focusTarget = null } = {}) { + const contextPanel = document.getElementById('global-context-panel'); + const scroller = contextPanel?.querySelector('.global-context-panel-inner'); + if (!contextPanel || contextPanel.classList.contains('collapsed') + || !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false; + + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (this.destroyed || contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false; + + const viewport = scroller.getBoundingClientRect(); + const radioRect = this._radioPanel.getBoundingClientRect(); + const desired = scroller.scrollTop + radioRect.top - viewport.top - 10; + const next = Math.min( + Math.max(0, scroller.scrollHeight - scroller.clientHeight), + Math.max(0, desired), + ); + const moved = Math.abs(next - scroller.scrollTop) >= 1; + if (moved) { + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; + scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); + } + focusTarget?.focus?.({ preventScroll: true }); + return moved; + } + + /** Keep the Context header Radio shortcut truthful for its current route. */ + _syncContextRadioLauncherState() { + if (!this._contextRadioToggleBtn) return; + const contextPanel = document.getElementById('global-context-panel'); + const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed')); + if (contextExpanded) { + const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed')); + this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel'); + this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded)); + const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context'; + this._contextRadioToggleBtn.setAttribute('aria-label', label); + this._contextRadioToggleBtn.title = label; + return; + } + const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open')); + this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini'); + this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen)); + const action = compactOpen ? 'Close' : 'Open'; + this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`); + this._contextRadioToggleBtn.title = `${action} compact Radio controls`; + } + + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.listeners.abort(); + this._radioUnsubscribe?.(); + this._radioUnsubscribe = null; + const pointer = this._radioTunerPointerId; + this._radioTunerPointerId = null; + this._radioTunerDragging = false; + this._radioTunerKeyboardKey = null; + this._radioTunerDragSnapshot = null; + this._radioTunerPool = []; + this._radioTunerStations = []; + this._radioTunerBandPinnedForNavigation = false; + try { if (pointer !== null) this._radioTunerSlider?.releasePointerCapture(pointer); } catch { /* capture already released */ } + this._radioTuner?.classList.remove('is-dragging', 'is-static'); + document.getElementById('title-bar')?.classList.remove('radio-broadcasting'); + this.radio.endTuning(); + } +} diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs new file mode 100644 index 000000000..89d128d40 --- /dev/null +++ b/src/ui/radioControls.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { RadioControls } from './radioControls.js'; + +class Element extends EventTarget { + constructor() { + super(); + this.attrs = new Map(); + this.classes = new Set(); + this.focusCount = 0; + this.isConnected = true; + this.classList = { + add: (...names) => names.forEach((n) => this.classes.add(n)), + remove: (...names) => names.forEach((n) => this.classes.delete(n)), + contains: (name) => this.classes.has(name), + toggle: (name, enabled) => enabled ? this.classes.add(name) : this.classes.delete(name), + }; + } + setAttribute(name, value) { this.attrs.set(name, String(value)); } + getAttribute(name) { return this.attrs.get(name); } + querySelector() { return null; } + focus() { this.focusCount++; } +} + +function fixture() { + const prior = { document: globalThis.document, window: globalThis.window }; + const document = new EventTarget(); + document.getElementById = () => null; + globalThis.document = document; + globalThis.window = new EventTarget(); + const panel = new Element(); + const enable = new Element(); + const next = new Element(); + const calls = []; + const subscribers = []; + const radio = { + subscribe(callback) { subscribers.push(callback); calls.push('subscribe'); return () => calls.push('unsubscribe'); }, + endTuning() { calls.push('endTuning'); }, + cycleStation(direction) { calls.push(['cycle', direction]); return true; }, + }; + const actions = { + isRegistered: () => true, isEnabled: () => false, + runUserAction: (operation) => operation(), + setEnabled: async () => true, + setPanelCollapsed: (...args) => calls.push(['panel', ...args]), + layoutCockpit() {}, isCockpitActive: () => false, + scheduleLayout() {}, getLifecycle: () => null, + preservePanelStateDuringClear: () => false, + }; + const controls = new RadioControls({ + elements: { _radioPanel: panel, _radioEnableBtn: enable, _radioNextBtn: next }, + radio, actions, canvas: new Element(), + }); + return { controls, enable, next, actions, calls, subscribers, document, + cleanup() { controls.destroy(); Object.assign(globalThis, prior); } }; +} + +test('Radio listeners and subscriptions are revoked once before tuning teardown', () => { + const f = fixture(); + try { + f.controls.connect(); + f.next.dispatchEvent(new Event('click')); + assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]); + f.controls.destroy(); f.controls.destroy(); + const snapshot = structuredClone(f.calls); + f.next.dispatchEvent(new Event('click')); + f.document.dispatchEvent(new Event('gev:radio-selected')); + f.subscribers[0]({ enabled: true }); + f.controls.connect(); + assert.deepEqual(f.calls, snapshot); + assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']); + } finally { f.cleanup(); } +}); + +test('Radio reconnect removes its previous state subscription', () => { + const f = fixture(); + try { + f.controls.connect(); f.controls.connect(); + assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']); + } finally { f.cleanup(); } +}); + +test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => { + const f = fixture(); + let resolve; + const pending = new Promise((done) => { resolve = done; }); + try { + f.actions.setEnabled = () => pending; + f.enable.dispatchEvent(new Event('click')); + assert.equal(f.enable.getAttribute('aria-busy'), 'true'); + f.controls.destroy(); + const attrs = new Map(f.enable.attrs); + resolve(true); + await pending; await new Promise((done) => setImmediate(done)); + assert.deepEqual(f.enable.attrs, attrs); + assert.equal(f.enable.focusCount, 0); + assert.equal(f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'), false); + } finally { f.cleanup(); } +}); + +test('destruction releases an active tuner pointer after revoking listeners', () => { + const f = fixture(); + try { + const released = []; + f.controls._radioTunerSlider = { releasePointerCapture(id) { + assert.equal(f.controls.listeners.signal.aborted, true); + released.push(id); + } }; + f.controls._radioTunerPointerId = 7; + f.controls._radioTunerDragging = true; + f.controls.destroy(); + assert.deepEqual(released, [7]); + assert.equal(f.controls._radioTunerDragging, false); + assert.equal(f.controls._radioTunerPointerId, null); + } finally { f.cleanup(); } +}); diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js new file mode 100644 index 000000000..46da1705b --- /dev/null +++ b/src/ui/radioPresentation.js @@ -0,0 +1,245 @@ + /** Render Radio state without making playback or Context decisions. */ +export function renderRadioState(state) { + if (this.destroyed || !state || !this._radioPanel) return; + const lifecycle = this.actions.getLifecycle() || null; + const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled'); + state = { + ...state, + enabled: lifecycle ? lifecycle.enabled : state.enabled, + lifecycleState, + lifecycleUncertain: lifecycle?.uncertain || false, + }; + this._radioState = state; + const enabled = Boolean(state.enabled); + const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling'; + const uncertain = Boolean(state.lifecycleUncertain); + const interactive = enabled && !transitioning && !uncertain; + const selected = state.selected || null; + const hasStations = state.filteredCount > 0; + const activePlayback = ['playing', 'buffering'].includes(state.audioState); + document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); + this._radioPanel.classList.toggle('radio-enabled', enabled); + this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain); + this._contextRadioDock?.classList.toggle('active', enabled); + if (this._contextRadioToggleBtn) { + this._contextRadioToggleBtn.classList.toggle('active', enabled); + } + this._syncContextRadioLauncherState(); + this._radioLayerState?.classList.toggle('active', enabled); + if (this._radioLayerState) { + this._radioLayerState.textContent = transitioning + ? lifecycleState.toUpperCase() + : (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF'))); + } + if (this._radioEnableBtn) { + this._radioEnableBtn.classList.toggle('active', enabled); + this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); + this._radioEnableBtn.textContent = transitioning + ? lifecycleState.toUpperCase() + : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); + this._radioEnableBtn.setAttribute( + 'aria-label', + uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, + ); + this._radioEnableBtn.disabled = false; + this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning)); + this._radioEnableBtn.setAttribute('aria-busy', String(transitioning)); + } + if (this._contextRadioMiniEnableBtn) { + this._contextRadioMiniEnableBtn.classList.toggle('active', enabled); + this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled)); + this._contextRadioMiniEnableBtn.textContent = transitioning + ? lifecycleState.toUpperCase() + : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); + this._contextRadioMiniEnableBtn.setAttribute( + 'aria-label', + uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, + ); + this._contextRadioMiniEnableBtn.disabled = false; + this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning)); + this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning)); + } + if (this._cockpitRadioEnableBtn) { + this._cockpitRadioEnableBtn.classList.toggle('active', enabled); + this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled)); + this._cockpitRadioEnableBtn.textContent = transitioning + ? lifecycleState.toUpperCase() + : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); + this._cockpitRadioEnableBtn.setAttribute( + 'aria-label', + uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, + ); + this._cockpitRadioEnableBtn.disabled = false; + this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning)); + this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning)); + } + + if (this._radioFilter) { + const prior = state.filter || 'all'; + const categorySignature = state.categories + .map((category) => `${category.id}:${category.count}:${category.color}`) + .join('|'); + if (categorySignature !== this._radioCategorySignature) { + this._radioFilter.replaceChildren(...state.categories.map((category) => { + const option = document.createElement('option'); + option.value = category.id; + option.textContent = `● ${category.label} (${category.count})`; + option.dataset.radioColor = category.color; + option.style.color = category.color; + option.setAttribute('aria-label', `${category.label} (${category.count})`); + return option; + })); + this._radioCategorySignature = categorySignature; + } + this._radioFilter.value = prior; + const activeCategory = state.categories.find((category) => category.id === prior); + this._radioFilter.style.color = activeCategory?.color || ''; + this._radioFilter.disabled = !interactive || !state.stationCount; + } + + const tunerAvailable = interactive && state.filteredCount > 0; + if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; + if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; + if (this._radioTunerBandLabel) { + const activeCategory = state.categories.find((category) => category.id === state.filter); + this._radioTunerBandLabel.textContent = state.filter === 'all' + ? 'DIRECTORY BAND' + : `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`; + } + this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic)); + if (tunerAvailable) this._refreshRadioTunerBand?.(); + if (!tunerAvailable && this._radioTunerDragging) { + this._radioTunerDragging = false; + this._radioTunerDragSnapshot = null; + this._radioTunerStations = []; + this._radioTuner?.classList.remove('is-static', 'is-dragging'); + } + if (!tunerAvailable) { + this._radioTunerStations = []; + this._radioTunerPool = []; + this._radioTunerBandSignature = ''; + this._radioTunerSelectedId = null; + } + + if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED'; + if (this._radioStationMeta) { + const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : ''; + const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : ''; + this._radioStationMeta.textContent = selected + ? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only' + : (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.'); + } + if (this._radioStationTags) { + const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; + this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : ''; + } + if (this._radioStationHomepage) { + const homepage = selected?.homepage || ''; + this._radioStationHomepage.hidden = !homepage; + if (homepage) this._radioStationHomepage.href = homepage; + else this._radioStationHomepage.removeAttribute('href'); + } + + if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations; + if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations; + if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations; + if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations; + if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations; + if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; + if (this._radioPlayBtn) { + const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); + this._radioPlayBtn.disabled = !interactive || !hasStations; + this._radioPlayBtn.classList.toggle('active', activePlayback); + this._radioPlayBtn.textContent = action.toUpperCase(); + this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); + } + if (this._contextRadioMiniPlayBtn) { + const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); + this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations; + this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback); + this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; + this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); + this._contextRadioMiniPlayBtn.title = action; + } + if (this._cockpitRadioPlayBtn) { + const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); + this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations; + this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback); + this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; + this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); + this._cockpitRadioPlayBtn.title = action; + } + if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped'; + if (this._radioVolume) this._radioVolume.disabled = !interactive; + if (this._radioVolume && document.activeElement !== this._radioVolume) { + this._radioVolume.value = String(Math.round(state.volume * 100)); + if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; + } + if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) { + this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100)); + } + if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive; + if (this._contextRadioMiniVolumeValue) { + this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; + } + if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) { + this._cockpitRadioVolume.value = String(Math.round(state.volume * 100)); + } + if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive; + if (this._cockpitRadioVolumeValue) { + this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; + } + if (this._contextRadioMiniStation) { + this._contextRadioMiniStation.textContent = uncertain + ? 'RADIO STATE UNCERTAIN' + : (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY')); + } + if (this._cockpitRadioStation) { + this._cockpitRadioStation.textContent = uncertain + ? 'UNCERTAIN' + : (selected?.name || (state.loading ? 'SYNCING' : 'READY')); + } + if (this._radioPlaybackState) { + const catalogSuffix = state.degraded + ? (state.stale ? ' · stale/degraded directory' : ' · degraded directory') + : (state.stale ? ' · stale directory' : ''); + const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : ''; + const messages = { + stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off', + loading: 'Connecting directly to broadcaster…', + buffering: 'Buffering broadcaster stream…', + playing: `Playing ${selected?.name || 'station'}`, + paused: `Paused ${selected?.name || 'station'}`, + error: state.audioError || 'Broadcaster stream unavailable', + }; + const voiceSuffix = state.voiceDucked + ? ' · muted during voice interaction' + : (state.voiceRestoring ? ' · restoring volume after voice' : ''); + const tuningSuffix = state.tuningAwaitingStationId + ? (state.audioState === 'error' + ? ' · static indicates no broadcaster audio' + : ' · tuning static until broadcaster starts') + : ''; + const unavailable = state.tuningUnavailableStationId + ? 'Station unavailable after directory refresh — choose another channel' + : null; + const lifecycleMessage = transitioning + ? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…') + : null; + const uncertainMessage = uncertain + ? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile' + : null; + this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; + this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error')); + } + if ( + !enabled + && !transitioning + && !this.actions.preservePanelStateDuringClear() + && !this._radioPanel.classList.contains('collapsed') + ) { + this.actions.setPanelCollapsed('radio-panel', true); + } + this.actions.scheduleLayout(); + } + diff --git a/src/ui/radioTunerModel.js b/src/ui/radioTunerModel.js new file mode 100644 index 000000000..5d328371f --- /dev/null +++ b/src/ui/radioTunerModel.js @@ -0,0 +1,87 @@ +/** Map an integer tuner slot directly to one available directory station. */ +export function radioTunerSlot(value, stationCount) { + const count = Math.max(0, Math.floor(Number(stationCount) || 0)); + if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 }; + const max = Math.max(0, count - 1); + const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0))); + return { + slot, + max, + locked: true, + stationIndex: slot, + leftIndex: slot, + rightIndex: slot, + }; +} + +/** Snap a tuner release to the nearest available directory station. */ +export function radioTunerCommitSlot(value, stationCount) { + return radioTunerSlot(value, stationCount); +} + +/** Map one pointer coordinate to continuous absolute directory progress. */ +export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) { + const count = Math.max(0, Math.floor(Number(stationCount) || 0)); + if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 }; + if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 }; + const inset = Math.max(0, Number(insetPx) || 0); + const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2); + const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth)); + const coordinate = ratio * (count - 1); + return { + ratio, + coordinate, + stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))), + }; +} + +/** Build a bounded virtual tuner tape around one continuous directory coordinate. */ +export function buildRadioTunerTicks(coordinate, stationCount, width, { + insetPx = 7, + minPitchPx = 14, + speedFactor = 5, + overscan = 2, + labelStep = 6, +} = {}) { + const count = Math.max(0, Math.floor(Number(stationCount) || 0)); + const dialWidth = Math.max(0, Number(width) || 0); + const inset = Math.max(0, Number(insetPx) || 0); + const usableWidth = Math.max(0, dialWidth - inset * 2); + if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 }; + const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0)); + if (count === 1) { + return { + ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }], + needleX: inset + usableWidth / 2, + pitchPx: Math.max(1, Number(minPitchPx) || 14), + ratio: 0.5, + }; + } + const directoryStep = usableWidth / (count - 1); + const pitchPx = Math.max( + Math.max(1, Number(minPitchPx) || 14), + directoryStep * Math.max(1, Number(speedFactor) || 5), + ); + const needleX = inset + directoryStep * value; + const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0); + const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx)); + const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx)); + const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5))); + const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6)); + const labelWidth = Math.max(2, String(count).length); + const ticks = []; + for (let stationIndex = first; stationIndex <= last; stationIndex += 1) { + const channel = stationIndex + 1; + const current = stationIndex === currentIndex; + const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0; + ticks.push({ + stationIndex, + channel, + xPx: needleX + pitchPx * (stationIndex - value), + current, + label: labelled ? String(channel).padStart(labelWidth, '0') : '', + }); + } + return { ticks, needleX, pitchPx, ratio: value / (count - 1) }; +} + From bd6cc5abe8513441aa97fb0ff087026cf131c330 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:25:09 -0700 Subject: [PATCH 23/92] style: format Radio component boundary --- scripts/package-boundaries.json | 216 ++---- src/ui/radio.js | 7 +- src/ui/radioBindings.js | 1138 ++++++++++++++++++++----------- src/ui/radioControls.js | 131 +++- src/ui/radioControls.test.mjs | 112 ++- src/ui/radioPresentation.js | 590 +++++++++------- src/ui/radioTunerModel.js | 82 ++- 7 files changed, 1393 insertions(+), 883 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6e9b94dce..b1435a095 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite" - ], - "modules": [ - "build/vite.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -65,24 +45,16 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -97,19 +69,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -120,19 +86,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -140,50 +100,30 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], - "modules": [ - "server/providers/traffic.js", - "src/data/tomtomTiles.js" - ], + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], - "modules": [ - "server/providers/firms.js", - "src/data/firmsCsv.js" - ], + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -192,19 +132,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -413,14 +314,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -430,10 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -444,9 +338,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -456,9 +348,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", diff --git a/src/ui/radio.js b/src/ui/radio.js index 496c23444..c8110b682 100644 --- a/src/ui/radio.js +++ b/src/ui/radio.js @@ -1,2 +1,7 @@ export { RadioControls } from './radioControls.js'; -export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; +export { + radioTunerSlot, + radioTunerCommitSlot, + radioTunerPointerPosition, + buildRadioTunerTicks, +} from './radioTunerModel.js'; diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index ef1af2d41..530e4b43e 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -1,488 +1,808 @@ -import { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; +import { + radioTunerSlot, + radioTunerCommitSlot, + radioTunerPointerPosition, + buildRadioTunerTicks, +} from './radioTunerModel.js'; /** Bind Radio input using a RadioControls receiver and supplied actions. */ export function bindRadioControls() { - if (!this._radioPanel) return; - const tunerListenerOptions = {}; - const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { - const open = Boolean(expanded); - this._contextRadioDock?.classList.toggle('disclosure-open', open); - if (this._contextRadioMini) this._contextRadioMini.hidden = !open; - this._syncContextRadioLauncherState(); - if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true }); - }; - this._setRadioDisclosure = setRadioDisclosure; - const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => { - const displayOpen = kind === 'display' && Boolean(expanded); - const radioOpen = kind === 'radio' && Boolean(expanded); - if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true); - if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen; - if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen; - this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control') - ?.classList.toggle('is-expanded', displayOpen); - this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control') - ?.classList.toggle('is-expanded', radioOpen); - this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen)); - this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen)); - if (displayOpen) this.actions.revealStyleParameters(); - if (this._cockpitDisplayToggleBtn) { - const action = displayOpen ? 'Collapse' : 'Expand'; - this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀'; - this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`); - this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`; - } - if (this._cockpitRadioToggleBtn) { - const action = radioOpen ? 'Collapse' : 'Expand'; - this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀'; - this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`); - this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`; - } - if (!expanded && returnFocus) { - (kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn) - ?.focus({ preventScroll: true }); - } - if (!displayOpen && !radioOpen - && this.actions.isCockpitActive() - && !this.actions.signalUserCollapsed()) { - this.actions.setSignalCollapsed(false); - } - this.actions.layoutCockpit(); - }; - this._setCockpitDisclosure = setCockpitDisclosure; - const syncTunerTape = (coordinate) => { - const scale = this._radioTuner?.querySelector('.radio-tuner-scale'); - const dial = this._radioTuner?.querySelector('.radio-tuner-dial'); - if (!scale || !dial) return null; - const model = buildRadioTunerTicks( - coordinate, - this._radioTunerStations.length, - dial.getBoundingClientRect().width, + if (!this._radioPanel) return; + const tunerListenerOptions = {}; + const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { + const open = Boolean(expanded); + this._contextRadioDock?.classList.toggle('disclosure-open', open); + if (this._contextRadioMini) this._contextRadioMini.hidden = !open; + this._syncContextRadioLauncherState(); + if (!open && returnFocus) + this._contextRadioToggleBtn?.focus({ preventScroll: true }); + }; + this._setRadioDisclosure = setRadioDisclosure; + const setCockpitDisclosure = ( + kind, + expanded, + { returnFocus = false } = {}, + ) => { + const displayOpen = kind === 'display' && Boolean(expanded); + const radioOpen = kind === 'radio' && Boolean(expanded); + if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true); + if (this._cockpitDisplayPanel) + this._cockpitDisplayPanel.hidden = !displayOpen; + if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen; + this._cockpitDisplayToggleBtn + ?.closest('.cockpit-utility-control') + ?.classList.toggle('is-expanded', displayOpen); + this._cockpitRadioToggleBtn + ?.closest('.cockpit-utility-control') + ?.classList.toggle('is-expanded', radioOpen); + this._cockpitDisplayToggleBtn?.setAttribute( + 'aria-expanded', + String(displayOpen), + ); + this._cockpitRadioToggleBtn?.setAttribute( + 'aria-expanded', + String(radioOpen), + ); + if (displayOpen) this.actions.revealStyleParameters(); + if (this._cockpitDisplayToggleBtn) { + const action = displayOpen ? 'Collapse' : 'Expand'; + this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀'; + this._cockpitDisplayToggleBtn.setAttribute( + 'aria-label', + `${action} Cockpit display options`, ); - while (scale.children.length < model.ticks.length) { - const tick = document.createElement('span'); - tick.className = 'radio-tuner-tick'; - scale.append(tick); - } - while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove(); - model.ticks.forEach((entry, index) => { - const tick = scale.children[index]; - tick.style.left = `${entry.xPx}px`; - tick.textContent = entry.label; - tick.dataset.stationIndex = String(entry.stationIndex); - tick.classList.toggle('is-current', entry.current); - }); - scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); - return model; - }; - const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => { - const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length); - const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null; - const resolvedCoordinate = this._radioTunerStations.length <= 1 + this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`; + } + if (this._cockpitRadioToggleBtn) { + const action = radioOpen ? 'Collapse' : 'Expand'; + this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀'; + this._cockpitRadioToggleBtn.setAttribute( + 'aria-label', + `${action} Cockpit Radio controls`, + ); + this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`; + } + if (!expanded && returnFocus) { + (kind === 'display' + ? this._cockpitDisplayToggleBtn + : this._cockpitRadioToggleBtn + )?.focus({ preventScroll: true }); + } + if ( + !displayOpen && + !radioOpen && + this.actions.isCockpitActive() && + !this.actions.signalUserCollapsed() + ) { + this.actions.setSignalCollapsed(false); + } + this.actions.layoutCockpit(); + }; + this._setCockpitDisclosure = setCockpitDisclosure; + const syncTunerTape = (coordinate) => { + const scale = this._radioTuner?.querySelector('.radio-tuner-scale'); + const dial = this._radioTuner?.querySelector('.radio-tuner-dial'); + if (!scale || !dial) return null; + const model = buildRadioTunerTicks( + coordinate, + this._radioTunerStations.length, + dial.getBoundingClientRect().width, + ); + while (scale.children.length < model.ticks.length) { + const tick = document.createElement('span'); + tick.className = 'radio-tuner-tick'; + scale.append(tick); + } + while (scale.children.length > model.ticks.length) + scale.lastElementChild?.remove(); + model.ticks.forEach((entry, index) => { + const tick = scale.children[index]; + tick.style.left = `${entry.xPx}px`; + tick.textContent = entry.label; + tick.dataset.stationIndex = String(entry.stationIndex); + tick.classList.toggle('is-current', entry.current); + }); + scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); + return model; + }; + const tunerPreview = ({ + coordinate = this._radioTunerCoordinate, + syncStatic = true, + rotate = true, + } = {}) => { + const slot = radioTunerSlot( + this._radioTunerSlider?.value, + this._radioTunerStations.length, + ); + const station = slot.locked + ? this._radioTunerStations[slot.stationIndex] || null + : null; + const resolvedCoordinate = + this._radioTunerStations.length <= 1 ? 0 - : Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0)); - const ratio = this._radioTunerStations.length === 1 + : Math.min( + this._radioTunerStations.length - 1, + Math.max(0, Number(coordinate) || 0), + ); + const ratio = + this._radioTunerStations.length === 1 ? 0.5 : resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1); - this._radioTunerCoordinate = resolvedCoordinate; - this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); - this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic)); - syncTunerTape(resolvedCoordinate); - if (this._radioTunerValue) { - this._radioTunerValue.textContent = station - ? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` - : 'NO STATIONS'; - } - if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE'; - if (this._radioTunerSlider) { - this._radioTunerSlider.setAttribute('aria-valuetext', station + this._radioTunerCoordinate = resolvedCoordinate; + this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); + this._radioTuner?.classList.toggle( + 'is-static', + syncStatic ? false : Boolean(this._radioState?.tuningStatic), + ); + syncTunerTape(resolvedCoordinate); + if (this._radioTunerValue) { + this._radioTunerValue.textContent = station + ? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` + : 'NO STATIONS'; + } + if (this._radioTunerStation) + this._radioTunerStation.textContent = + station?.name || 'NO STATION AVAILABLE'; + if (this._radioTunerSlider) { + this._radioTunerSlider.setAttribute( + 'aria-valuetext', + station ? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}` - : 'No station available'); - } - if (syncStatic) this.radio.previewTuningStation(station?.id || null, { rotate }); - return station; + : 'No station available', + ); + } + if (syncStatic) + this.radio.previewTuningStation(station?.id || null, { rotate }); + return station; + }; + const setTunerDirectory = (pool) => { + this._radioTunerPool = [...pool]; + this._radioTunerStations = [...pool]; + this._radioTunerBandSignature = this._radioTunerStations + .map((station) => station.id) + .join('|'); + if (this._radioTunerSlider) { + this._radioTunerSlider.min = '0'; + this._radioTunerSlider.max = String( + Math.max(0, this._radioTunerStations.length - 1), + ); + this._radioTunerSlider.step = '1'; + } + }; + const refreshTunerBand = ({ force = false } = {}) => { + if ( + this._radioTunerDragging || + this._radioTuner?.hidden || + this._radioTunerSlider?.disabled + ) + return false; + const selectedId = this._radioState?.selected?.id || null; + const pool = this.radio.getTunerStations(750); + const poolSignature = pool.map((station) => station.id).join('|'); + const currentPoolSignature = this._radioTunerPool + .map((station) => station.id) + .join('|'); + if ( + !force && + poolSignature === currentPoolSignature && + selectedId === this._radioTunerSelectedId + ) + return false; + setTunerDirectory(pool); + this._radioTunerSelectedId = selectedId; + const selectedPoolIndex = pool.findIndex( + (station) => station.id === selectedId, + ); + const slot = radioTunerSlot( + selectedPoolIndex >= 0 ? selectedPoolIndex : 0, + this._radioTunerStations.length, + ); + this._radioTunerSlider.value = String(slot.slot); + this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; + tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); + return true; + }; + this._refreshRadioTunerBand = refreshTunerBand; + const beginTuner = () => { + if (this._radioTunerDragging || this._radioTunerSlider?.disabled) + return false; + refreshTunerBand(); + if (!this._radioTunerStations.length || !this.radio.beginTuning()) + return false; + // A tuner-owned camera preview must never replace the frozen directory. + // Only an explicit globe pointer/wheel gesture releases camera pinning. + this._radioTunerBandPinnedForNavigation = true; + this._radioTunerDragging = true; + this._radioTuner?.classList.add('is-dragging'); + const selectedIndex = this._radioTunerStations.findIndex( + (station) => station.id === this._radioState?.selected?.id, + ); + const slot = radioTunerSlot( + selectedIndex >= 0 ? selectedIndex : 0, + this._radioTunerStations.length, + ); + this._radioTunerSlider.value = String(slot.slot); + this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; + this._radioTunerDragStartSlot = slot.slot; + this._radioTunerDragSnapshot = { + stations: [...this._radioTunerStations], + bandSignature: this._radioTunerBandSignature, + selectedId: this._radioTunerSelectedId, + slot: slot.slot, + coordinate: this._radioTunerCoordinate, }; - const setTunerDirectory = (pool) => { - this._radioTunerPool = [...pool]; - this._radioTunerStations = [...pool]; - this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|'); + this._radioTunerLastSlot = slot.slot; + this._radioTunerDragDirection = 0; + tunerPreview({ coordinate: this._radioTunerCoordinate }); + return true; + }; + const finishTuner = (commit) => { + if (!this._radioTunerDragging) return; + const dragSnapshot = this._radioTunerDragSnapshot; + if (!commit && dragSnapshot) { + this._radioTunerStations = [...dragSnapshot.stations]; + this._radioTunerPool = [...dragSnapshot.stations]; + this._radioTunerBandSignature = dragSnapshot.bandSignature; + this._radioTunerSelectedId = dragSnapshot.selectedId; if (this._radioTunerSlider) { - this._radioTunerSlider.min = '0'; - this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1)); - this._radioTunerSlider.step = '1'; - } - }; - const refreshTunerBand = ({ force = false } = {}) => { - if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false; - const selectedId = this._radioState?.selected?.id || null; - const pool = this.radio.getTunerStations(750); - const poolSignature = pool.map((station) => station.id).join('|'); - const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|'); - if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false; - setTunerDirectory(pool); - this._radioTunerSelectedId = selectedId; - const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId); - const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length); - this._radioTunerSlider.value = String(slot.slot); - this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; - tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); - return true; - }; - this._refreshRadioTunerBand = refreshTunerBand; - const beginTuner = () => { - if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false; - refreshTunerBand(); - if (!this._radioTunerStations.length || !this.radio.beginTuning()) return false; - // A tuner-owned camera preview must never replace the frozen directory. - // Only an explicit globe pointer/wheel gesture releases camera pinning. - this._radioTunerBandPinnedForNavigation = true; - this._radioTunerDragging = true; - this._radioTuner?.classList.add('is-dragging'); - const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id); - const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length); - this._radioTunerSlider.value = String(slot.slot); - this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; - this._radioTunerDragStartSlot = slot.slot; - this._radioTunerDragSnapshot = { - stations: [...this._radioTunerStations], - bandSignature: this._radioTunerBandSignature, - selectedId: this._radioTunerSelectedId, - slot: slot.slot, - coordinate: this._radioTunerCoordinate, - }; - this._radioTunerLastSlot = slot.slot; - this._radioTunerDragDirection = 0; - tunerPreview({ coordinate: this._radioTunerCoordinate }); - return true; - }; - const finishTuner = (commit) => { - if (!this._radioTunerDragging) return; - const dragSnapshot = this._radioTunerDragSnapshot; - if (!commit && dragSnapshot) { - this._radioTunerStations = [...dragSnapshot.stations]; - this._radioTunerPool = [...dragSnapshot.stations]; - this._radioTunerBandSignature = dragSnapshot.bandSignature; - this._radioTunerSelectedId = dragSnapshot.selectedId; - if (this._radioTunerSlider) { - const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length); - this._radioTunerSlider.max = String(startSlot.max); - this._radioTunerSlider.value = String(startSlot.slot); - } - this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate) - ? dragSnapshot.coordinate - : dragSnapshot.slot; - } else if (!commit && this._radioTunerSlider) { - this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); - this._radioTunerCoordinate = this._radioTunerDragStartSlot; - } - let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit }); - if (commit && !station && this._radioTunerSlider) { - const snapped = radioTunerCommitSlot( - this._radioTunerSlider.value, + const startSlot = radioTunerSlot( + dragSnapshot.slot, this._radioTunerStations.length, ); - this._radioTunerSlider.value = String(snapped.slot); - this._radioTunerCoordinate = snapped.stationIndex; - station = tunerPreview({ coordinate: this._radioTunerCoordinate }); + this._radioTunerSlider.max = String(startSlot.max); + this._radioTunerSlider.value = String(startSlot.slot); } - let result = null; - if (commit && station) { - // Keep the exact band used by the drag so the selected channel cannot - // jump to a refreshed catalog slot while its camera flight settles. - this._radioTunerBandPinnedForNavigation = true; - result = this.radio.commitTuningStation(station.id, { origin: 'user' }); - } else if (!commit) { - this.radio.cancelTuning(); - } else { - this.radio.endTuning(); - } - // Radio emits selection/tuning state synchronously. Keep both the logical - // drag and the no-transition class active until that state has settled, - // then restore the exact selected slot before permitting CSS motion. - this._radioTunerDragging = false; - this._radioTunerPointerId = null; - this._radioTunerKeyboardKey = null; - if (commit && (!result || result.ok)) refreshTunerBand(); - this._radioTunerDragSnapshot = null; - // Flush the snapped position while transitions are still disabled so - // removing the drag class cannot interpolate from the released gap. - void this._radioTunerNeedle?.offsetLeft; - this._radioTuner?.classList.remove('is-dragging'); - if (result && !result.ok) { - this._radioTunerBandPinnedForNavigation = false; - if (result.reason === 'station-unavailable') { - if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR'; - if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE'; - this._radioTunerSlider?.setAttribute( - 'aria-valuetext', - 'Station unavailable after directory refresh', - ); - } - } - }; - const cycleRadio = (direction, { rotate = true } = {}) => { + this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate) + ? dragSnapshot.coordinate + : dragSnapshot.slot; + } else if (!commit && this._radioTunerSlider) { + this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); + this._radioTunerCoordinate = this._radioTunerDragStartSlot; + } + let station = tunerPreview({ + coordinate: this._radioTunerCoordinate, + rotate: commit, + }); + if (commit && !station && this._radioTunerSlider) { + const snapped = radioTunerCommitSlot( + this._radioTunerSlider.value, + this._radioTunerStations.length, + ); + this._radioTunerSlider.value = String(snapped.slot); + this._radioTunerCoordinate = snapped.stationIndex; + station = tunerPreview({ coordinate: this._radioTunerCoordinate }); + } + let result = null; + if (commit && station) { + // Keep the exact band used by the drag so the selected channel cannot + // jump to a refreshed catalog slot while its camera flight settles. this._radioTunerBandPinnedForNavigation = true; - const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; - const cycled = this.radio.cycleStation(direction, { - rotate, - stationIds: pool.map((station) => station.id), - origin: 'user', - }); - if (!cycled) { - this._radioTunerBandPinnedForNavigation = false; - return; + result = this.radio.commitTuningStation(station.id, { origin: 'user' }); + } else if (!commit) { + this.radio.cancelTuning(); + } else { + this.radio.endTuning(); + } + // Radio emits selection/tuning state synchronously. Keep both the logical + // drag and the no-transition class active until that state has settled, + // then restore the exact selected slot before permitting CSS motion. + this._radioTunerDragging = false; + this._radioTunerPointerId = null; + this._radioTunerKeyboardKey = null; + if (commit && (!result || result.ok)) refreshTunerBand(); + this._radioTunerDragSnapshot = null; + // Flush the snapped position while transitions are still disabled so + // removing the drag class cannot interpolate from the released gap. + void this._radioTunerNeedle?.offsetLeft; + this._radioTuner?.classList.remove('is-dragging'); + if (result && !result.ok) { + this._radioTunerBandPinnedForNavigation = false; + if (result.reason === 'station-unavailable') { + if (this._radioTunerValue) + this._radioTunerValue.textContent = 'OFF AIR'; + if (this._radioTunerStation) + this._radioTunerStation.textContent = 'STATION UNAVAILABLE'; + this._radioTunerSlider?.setAttribute( + 'aria-valuetext', + 'Station unavailable after directory refresh', + ); } - }; - const toggleRadio = async (trigger) => { - if (!this.actions.isRegistered()) return; - if (trigger.getAttribute('aria-busy') === 'true') return; - const enabling = !this.actions.isEnabled(); - const revealAfterEnable = enabling && trigger === this._radioEnableBtn; - trigger.setAttribute('aria-disabled', 'true'); - trigger.setAttribute('aria-busy', 'true'); - try { - const toggled = await this.actions.runUserAction( - (notificationToken) => this.actions.setEnabled(enabling, { + } + }; + const cycleRadio = (direction, { rotate = true } = {}) => { + this._radioTunerBandPinnedForNavigation = true; + const pool = this._radioTunerPool.length + ? this._radioTunerPool + : this._radioTunerStations; + const cycled = this.radio.cycleStation(direction, { + rotate, + stationIds: pool.map((station) => station.id), + origin: 'user', + }); + if (!cycled) { + this._radioTunerBandPinnedForNavigation = false; + return; + } + }; + const toggleRadio = async (trigger) => { + if (!this.actions.isRegistered()) return; + if (trigger.getAttribute('aria-busy') === 'true') return; + const enabling = !this.actions.isEnabled(); + const revealAfterEnable = enabling && trigger === this._radioEnableBtn; + trigger.setAttribute('aria-disabled', 'true'); + trigger.setAttribute('aria-busy', 'true'); + try { + const toggled = await this.actions.runUserAction( + (notificationToken) => + this.actions.setEnabled(enabling, { origin: 'user', notificationToken, }), - `Radio could not ${enabling ? 'start' : 'stop'} cleanly`, - ); - if (this.destroyed || toggled === false) return; - if (enabling && trigger === this._radioEnableBtn - && !document.getElementById('global-context-panel')?.classList.contains('collapsed')) { - this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); - } - if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger); - } finally { - if (!this.destroyed) { - trigger.setAttribute('aria-disabled', 'false'); - trigger.setAttribute('aria-busy', 'false'); - if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true }); - } - } - }; - this.listen(this._radioEnableBtn, 'click', () => void toggleRadio(this._radioEnableBtn)); - this.listen(this._contextRadioMiniEnableBtn, 'click', () => void toggleRadio(this._contextRadioMiniEnableBtn)); - this.listen(this._cockpitRadioEnableBtn, 'click', () => void toggleRadio(this._cockpitRadioEnableBtn)); - this.listen(this._contextRadioToggleBtn, 'click', () => { - const contextPanel = document.getElementById('global-context-panel'); - if (contextPanel && !contextPanel.classList.contains('collapsed')) { - setRadioDisclosure(false); - this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); - void this._revealRadioPanelInsideContext({ - focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'), + `Radio could not ${enabling ? 'start' : 'stop'} cleanly`, + ); + if (this.destroyed || toggled === false) return; + if ( + enabling && + trigger === this._radioEnableBtn && + !document + .getElementById('global-context-panel') + ?.classList.contains('collapsed') + ) { + this.actions.setPanelCollapsed('radio-panel', false, { + explicit: true, }); - return; } - setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open')); - }); - this.listen(this._contextRadioMiniCloseBtn, 'click', () => { - setRadioDisclosure(false, { returnFocus: true }); - }); - this.listen(this._contextRadioDetailsBtn, 'click', () => { - if (!this.actions.isCockpitActive()) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); - this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); + if (revealAfterEnable) + await this._revealRadioControlsAfterExplicitEnable(trigger); + } finally { + if (!this.destroyed) { + trigger.setAttribute('aria-disabled', 'false'); + trigger.setAttribute('aria-busy', 'false'); + if (revealAfterEnable && trigger.isConnected) + trigger.focus({ preventScroll: true }); + } + } + }; + this.listen( + this._radioEnableBtn, + 'click', + () => void toggleRadio(this._radioEnableBtn), + ); + this.listen( + this._contextRadioMiniEnableBtn, + 'click', + () => void toggleRadio(this._contextRadioMiniEnableBtn), + ); + this.listen( + this._cockpitRadioEnableBtn, + 'click', + () => void toggleRadio(this._cockpitRadioEnableBtn), + ); + this.listen(this._contextRadioToggleBtn, 'click', () => { + const contextPanel = document.getElementById('global-context-panel'); + if (contextPanel && !contextPanel.classList.contains('collapsed')) { setRadioDisclosure(false); - this._radioEnableBtn?.focus({ preventScroll: true }); - }); - this.listen(this._cockpitRadioToggleBtn, 'click', () => { - const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true'; - setCockpitDisclosure('radio', !open); - }); - this.listen(document, 'pointerdown', (event) => { - if (!this._contextRadioDock?.classList.contains('disclosure-open')) return; + this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); + void this._revealRadioPanelInsideContext({ + focusTarget: this._radioPanel?.querySelector( + '[data-collapse-target="radio-panel"]', + ), + }); + return; + } + setRadioDisclosure( + !this._contextRadioDock?.classList.contains('disclosure-open'), + ); + }); + this.listen(this._contextRadioMiniCloseBtn, 'click', () => { + setRadioDisclosure(false, { returnFocus: true }); + }); + this.listen(this._contextRadioDetailsBtn, 'click', () => { + if (!this.actions.isCockpitActive()) + this.actions.setPanelCollapsed('global-context-panel', false, { + explicit: true, + }); + this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); + setRadioDisclosure(false); + this._radioEnableBtn?.focus({ preventScroll: true }); + }); + this.listen(this._cockpitRadioToggleBtn, 'click', () => { + const open = + this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true'; + setCockpitDisclosure('radio', !open); + }); + this.listen( + document, + 'pointerdown', + (event) => { + if (!this._contextRadioDock?.classList.contains('disclosure-open')) + return; if (event.target?.closest?.('#context-radio-dock')) return; setRadioDisclosure(false); - }, tunerListenerOptions); - this.listen(document, 'pointerdown', (event) => { - if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return; + }, + tunerListenerOptions, + ); + this.listen( + document, + 'pointerdown', + (event) => { + if ( + !this._cockpitUtilityControls || + event.target?.closest?.('#cockpit-utility-controls') + ) + return; if (event.target?.closest?.('.cockpit-vision-controls')) return; - if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return; + if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) + return; setCockpitDisclosure('display', false); setCockpitDisclosure('radio', false); - }, tunerListenerOptions); - this.listen(document, 'keydown', (event) => { - if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return; + }, + tunerListenerOptions, + ); + this.listen( + document, + 'keydown', + (event) => { + if ( + event.key !== 'Escape' || + !this._contextRadioDock?.classList.contains('disclosure-open') + ) + return; event.preventDefault(); // Immediate: a plain stopPropagation() still lets every LATER listener on // this same document run, so closing the disclosure ALSO dismissed the // first-run launcher — one key, two actions. Matches the cockpit // disclosure handler directly below. event.stopImmediatePropagation(); - const escapedFromDisclosure = event.target === this._contextRadioToggleBtn - || this._contextRadioToggleBtn?.contains?.(event.target); + const escapedFromDisclosure = + event.target === this._contextRadioToggleBtn || + this._contextRadioToggleBtn?.contains?.(event.target); setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure }); if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.(); - }, { capture: true, }); - this.listen(document, 'keydown', (event) => { + }, + { capture: true }, + ); + this.listen( + document, + 'keydown', + (event) => { if (event.key !== 'Escape') return; - const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true'; - const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true'; + const displayOpen = + this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true'; + const radioOpen = + this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true'; if (!displayOpen && !radioOpen) return; - const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)'); + const nestedPanel = event.target?.closest?.( + '.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)', + ); if (nestedPanel) return; event.preventDefault(); event.stopImmediatePropagation(); const kind = displayOpen ? 'display' : 'radio'; - const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn; - const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); - setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure }); + const disclosure = displayOpen + ? this._cockpitDisplayToggleBtn + : this._cockpitRadioToggleBtn; + const escapedFromDisclosure = + event.target === disclosure || disclosure?.contains?.(event.target); + setCockpitDisclosure(kind, false, { + returnFocus: !escapedFromDisclosure, + }); if (escapedFromDisclosure) disclosure?.blur?.(); - }, { capture: true, }); - this.listen(window, 'gev:cockpit-mode-changed', (event) => { + }, + { capture: true }, + ); + this.listen( + window, + 'gev:cockpit-mode-changed', + (event) => { if (event?.detail?.active) return; setCockpitDisclosure('display', false); setCockpitDisclosure('radio', false); - }, tunerListenerOptions); - this.listen(window, 'gev:cockpit-signal-expanded', () => { + }, + tunerListenerOptions, + ); + this.listen( + window, + 'gev:cockpit-signal-expanded', + () => { setCockpitDisclosure('display', false); - }, tunerListenerOptions); - this.listen(window, 'gev:cockpit-context-expanded', () => { + }, + tunerListenerOptions, + ); + this.listen( + window, + 'gev:cockpit-context-expanded', + () => { this.actions.setPanelCollapsed('data-panel', true); - }, tunerListenerOptions); - this.listen(this._radioFilter, 'change', () => { - const presentation = this.radio.getUIState(); - if (!presentation.presentationActive) { - this._radioFilter.value = presentation.filter; - return; - } - if (this._radioTunerDragging) finishTuner(false); - if (!this.actions.setParams({ - filter: this._radioFilter.value, - }, { origin: 'user' })) { - this._radioFilter.value = this.radio.getUIState().filter; - return; - } - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerPool = []; - refreshTunerBand({ force: true }); - }); - this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1)); - this.listen(this._radioNextBtn, 'click', () => cycleRadio(1)); - this.listen(this._radioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); - this.listen(this._radioStopBtn, 'click', () => this.radio.stopPlayback({ origin: 'user' })); - this.listen(this._radioVolume, 'input', () => { - const value = Number(this._radioVolume.value); - if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`; - this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); - }); - this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1)); - this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1)); - this.listen(this._contextRadioMiniPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); - // Cockpit owns the Cesium camera even though it intentionally clears - // viewer.trackedEntity. Station changes must never start the map-view - // rotation/fallback flights that would compete with its preUpdate pose. - this.listen(this._cockpitRadioPrevBtn, 'click', () => cycleRadio(-1, { rotate: false })); - this.listen(this._cockpitRadioNextBtn, 'click', () => cycleRadio(1, { rotate: false })); - this.listen(this._cockpitRadioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); - this.listen(this._contextRadioMiniVolume, 'input', () => { - const value = Number(this._contextRadioMiniVolume.value); - if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`; - this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); - }); - this.listen(this._cockpitRadioVolume, 'input', () => { - const value = Number(this._cockpitRadioVolume.value); - if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`; - this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); - }); - const updateTunerFromPointer = (event) => { - const rect = this._radioTunerSlider?.getBoundingClientRect(); - if (!rect || !this._radioTunerStations.length) return false; - const position = radioTunerPointerPosition( - event.clientX, - rect.left, - rect.width, - this._radioTunerStations.length, - ); - if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; - this._radioTunerLastSlot = position.stationIndex; - this._radioTunerCoordinate = position.coordinate; - this._radioTunerSlider.value = String(position.stationIndex); - tunerPreview({ coordinate: position.coordinate }); - return true; - }; - this.listen(this._radioTunerSlider, 'pointerdown', (event) => { + }, + tunerListenerOptions, + ); + this.listen(this._radioFilter, 'change', () => { + const presentation = this.radio.getUIState(); + if (!presentation.presentationActive) { + this._radioFilter.value = presentation.filter; + return; + } + if (this._radioTunerDragging) finishTuner(false); + if ( + !this.actions.setParams( + { + filter: this._radioFilter.value, + }, + { origin: 'user' }, + ) + ) { + this._radioFilter.value = this.radio.getUIState().filter; + return; + } + this._radioTunerBandPinnedForNavigation = false; + this._radioTunerPool = []; + refreshTunerBand({ force: true }); + }); + this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1)); + this.listen(this._radioNextBtn, 'click', () => cycleRadio(1)); + this.listen( + this._radioPlayBtn, + 'click', + () => void this.radio.togglePlayback({ origin: 'user' }), + ); + this.listen(this._radioStopBtn, 'click', () => + this.radio.stopPlayback({ origin: 'user' }), + ); + this.listen(this._radioVolume, 'input', () => { + const value = Number(this._radioVolume.value); + if (this._radioVolumeValue) + this._radioVolumeValue.textContent = `${value}%`; + this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); + }); + this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1)); + this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1)); + this.listen( + this._contextRadioMiniPlayBtn, + 'click', + () => void this.radio.togglePlayback({ origin: 'user' }), + ); + // Cockpit owns the Cesium camera even though it intentionally clears + // viewer.trackedEntity. Station changes must never start the map-view + // rotation/fallback flights that would compete with its preUpdate pose. + this.listen(this._cockpitRadioPrevBtn, 'click', () => + cycleRadio(-1, { rotate: false }), + ); + this.listen(this._cockpitRadioNextBtn, 'click', () => + cycleRadio(1, { rotate: false }), + ); + this.listen( + this._cockpitRadioPlayBtn, + 'click', + () => void this.radio.togglePlayback({ origin: 'user' }), + ); + this.listen(this._contextRadioMiniVolume, 'input', () => { + const value = Number(this._contextRadioMiniVolume.value); + if (this._contextRadioMiniVolumeValue) + this._contextRadioMiniVolumeValue.textContent = `${value}%`; + this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); + }); + this.listen(this._cockpitRadioVolume, 'input', () => { + const value = Number(this._cockpitRadioVolume.value); + if (this._cockpitRadioVolumeValue) + this._cockpitRadioVolumeValue.textContent = `${value}%`; + this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); + }); + const updateTunerFromPointer = (event) => { + const rect = this._radioTunerSlider?.getBoundingClientRect(); + if (!rect || !this._radioTunerStations.length) return false; + const position = radioTunerPointerPosition( + event.clientX, + rect.left, + rect.width, + this._radioTunerStations.length, + ); + if (position.stationIndex > this._radioTunerLastSlot) + this._radioTunerDragDirection = 1; + else if (position.stationIndex < this._radioTunerLastSlot) + this._radioTunerDragDirection = -1; + this._radioTunerLastSlot = position.stationIndex; + this._radioTunerCoordinate = position.coordinate; + this._radioTunerSlider.value = String(position.stationIndex); + tunerPreview({ coordinate: position.coordinate }); + return true; + }; + this.listen( + this._radioTunerSlider, + 'pointerdown', + (event) => { if (!beginTuner()) return; this._radioTunerPointerId = event.pointerId; this._radioTunerSlider.focus({ preventScroll: true }); - try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ } + try { + this._radioTunerSlider.setPointerCapture(event.pointerId); + } catch { + /* capture is best effort */ + } updateTunerFromPointer(event); event.preventDefault(); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'pointermove', (event) => { - if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return; + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'pointermove', + (event) => { + if ( + !this._radioTunerDragging || + this._radioTunerPointerId !== event.pointerId + ) + return; updateTunerFromPointer(event); event.preventDefault(); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'input', () => { - if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return; + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'input', + () => { + if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) + return; if (!this._radioTunerDragging && !beginTuner()) return; - const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length); - if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; + const inputSlot = radioTunerSlot( + this._radioTunerSlider.value, + this._radioTunerStations.length, + ); + if (inputSlot.slot > this._radioTunerLastSlot) + this._radioTunerDragDirection = 1; + else if (inputSlot.slot < this._radioTunerLastSlot) + this._radioTunerDragDirection = -1; this._radioTunerLastSlot = inputSlot.slot; this._radioTunerCoordinate = inputSlot.stationIndex; tunerPreview({ coordinate: this._radioTunerCoordinate }); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'change', () => { - if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'pointerup', (event) => { + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'change', + () => { + if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) + finishTuner(true); + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'pointerup', + (event) => { if (this._radioTunerPointerId !== event.pointerId) return; updateTunerFromPointer(event); finishTuner(true); - try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } + try { + this._radioTunerSlider.releasePointerCapture(event.pointerId); + } catch { + /* already released */ + } event.preventDefault(); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'pointercancel', (event) => { - if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return; - try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'pointercancel', + (event) => { + if ( + this._radioTunerPointerId !== null && + this._radioTunerPointerId !== event.pointerId + ) + return; + try { + this._radioTunerSlider.releasePointerCapture(event.pointerId); + } catch { + /* already released */ + } finishTuner(false); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'lostpointercapture', (event) => { - if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'keydown', (event) => { + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'lostpointercapture', + (event) => { + if ( + this._radioTunerDragging && + this._radioTunerPointerId === event.pointerId + ) + finishTuner(false); + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'keydown', + (event) => { if (event.key === 'Escape' && this._radioTunerDragging) { event.preventDefault(); finishTuner(false); return; } - if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return; + if ( + ![ + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown', + ].includes(event.key) + ) + return; if (!this._radioTunerDragging && !beginTuner()) return; event.preventDefault(); this._radioTunerKeyboardKey = event.key; const max = Math.max(0, this._radioTunerStations.length - 1); - const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot; + const current = radioTunerSlot( + this._radioTunerSlider.value, + this._radioTunerStations.length, + ).slot; const page = Math.max(1, Math.round(max / 10)); - const next = event.key === 'Home' ? 0 - : event.key === 'End' ? max - : event.key === 'PageUp' ? current + page - : event.key === 'PageDown' ? current - page - : current + (event.key === 'ArrowRight' ? 1 : -1); + const next = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? max + : event.key === 'PageUp' + ? current + page + : event.key === 'PageDown' + ? current - page + : current + (event.key === 'ArrowRight' ? 1 : -1); const slot = radioTunerSlot(next, this._radioTunerStations.length); - if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; - else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; + if (slot.slot > this._radioTunerLastSlot) + this._radioTunerDragDirection = 1; + else if (slot.slot < this._radioTunerLastSlot) + this._radioTunerDragDirection = -1; this._radioTunerLastSlot = slot.slot; this._radioTunerCoordinate = slot.stationIndex; this._radioTunerSlider.value = String(slot.slot); tunerPreview({ coordinate: this._radioTunerCoordinate }); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'keyup', (event) => { - if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return; + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'keyup', + (event) => { + if ( + !this._radioTunerKeyboardKey || + event.key !== this._radioTunerKeyboardKey + ) + return; event.preventDefault(); finishTuner(true); - }, tunerListenerOptions); - this.listen(this._radioTunerSlider, 'blur', () => finishTuner(true), tunerListenerOptions); - const releaseNavigationBand = () => { - this._radioTunerBandPinnedForNavigation = false; - }; - this.listen(this.canvas, 'pointerdown', releaseNavigationBand, tunerListenerOptions); - this.listen(this.canvas, 'wheel', releaseNavigationBand, tunerListenerOptions); - // The directory order is catalog/filter authority, not camera authority. - // Globe motion therefore never rebuilds or re-ranks the frequency band. - this._radioSelectedHandler = () => this.actions.setPanelCollapsed('radio-panel', false); - this.listen(document, 'gev:radio-selected', this._radioSelectedHandler); - } - + }, + tunerListenerOptions, + ); + this.listen( + this._radioTunerSlider, + 'blur', + () => finishTuner(true), + tunerListenerOptions, + ); + const releaseNavigationBand = () => { + this._radioTunerBandPinnedForNavigation = false; + }; + this.listen( + this.canvas, + 'pointerdown', + releaseNavigationBand, + tunerListenerOptions, + ); + this.listen( + this.canvas, + 'wheel', + releaseNavigationBand, + tunerListenerOptions, + ); + // The directory order is catalog/filter authority, not camera authority. + // Globe motion therefore never rebuilds or re-ranks the frequency band. + this._radioSelectedHandler = () => + this.actions.setPanelCollapsed('radio-panel', false); + this.listen(document, 'gev:radio-selected', this._radioSelectedHandler); +} diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index a0379509f..df2cc3b2b 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -31,17 +31,24 @@ export class RadioControls { } listen(target, type, handler, options = {}) { - target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal }); + target?.addEventListener(type, handler, { + ...options, + signal: this.listeners.signal, + }); } connect() { this._radioUnsubscribe?.(); this._radioUnsubscribe = null; if (this.destroyed) return; - this._radioUnsubscribe = this.radio.subscribe?.((state) => this._renderRadioState(state)); + this._radioUnsubscribe = this.radio.subscribe?.((state) => + this._renderRadioState(state), + ); } - _renderRadioState(state) { renderRadioState.call(this, state); } + _renderRadioState(state) { + renderRadioState.call(this, state); + } /** * Reveal the newly enabled directory and transport inside Context without @@ -55,28 +62,46 @@ export class RadioControls { const scroller = contextPanel?.querySelector('.global-context-panel-inner'); const directory = this._radioPanel?.querySelector('.radio-directory-row'); const transport = this._radioPanel?.querySelector('.radio-transport'); - if (!contextPanel || contextPanel.classList.contains('collapsed') - || !scroller || !directory || !transport || !this._radioState?.enabled) return false; + if ( + !contextPanel || + contextPanel.classList.contains('collapsed') || + !scroller || + !directory || + !transport || + !this._radioState?.enabled + ) + return false; - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected) return false; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected) + return false; const viewport = scroller.getBoundingClientRect(); const directoryRect = directory.getBoundingClientRect(); const transportRect = transport.getBoundingClientRect(); const margin = 10; - const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); - const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin); - const desired = minimum <= maximum - ? Math.min(Math.max(scroller.scrollTop, minimum), maximum) - : minimum; + const minimum = + scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); + const maximum = + scroller.scrollTop + directoryRect.top - (viewport.top + margin); + const desired = + minimum <= maximum + ? Math.min(Math.max(scroller.scrollTop, minimum), maximum) + : minimum; const next = Math.min( Math.max(0, scroller.scrollHeight - scroller.clientHeight), Math.max(0, desired), ); if (Math.abs(next - scroller.scrollTop) < 1) return false; - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); + const reducedMotion = window.matchMedia?.( + '(prefers-reduced-motion: reduce)', + )?.matches; + scroller.scrollTo({ + top: next, + behavior: reducedMotion ? 'auto' : 'smooth', + }); return true; } @@ -89,11 +114,24 @@ export class RadioControls { async _revealRadioPanelInsideContext({ focusTarget = null } = {}) { const contextPanel = document.getElementById('global-context-panel'); const scroller = contextPanel?.querySelector('.global-context-panel-inner'); - if (!contextPanel || contextPanel.classList.contains('collapsed') - || !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false; + if ( + !contextPanel || + contextPanel.classList.contains('collapsed') || + !scroller || + !this._radioPanel || + this._radioPanel.classList.contains('collapsed') + ) + return false; - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (this.destroyed || contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + if ( + this.destroyed || + contextPanel.classList.contains('collapsed') || + this._radioPanel.classList.contains('collapsed') + ) + return false; const viewport = scroller.getBoundingClientRect(); const radioRect = this._radioPanel.getBoundingClientRect(); @@ -104,8 +142,13 @@ export class RadioControls { ); const moved = Math.abs(next - scroller.scrollTop) >= 1; if (moved) { - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); + const reducedMotion = window.matchMedia?.( + '(prefers-reduced-motion: reduce)', + )?.matches; + scroller.scrollTo({ + top: next, + behavior: reducedMotion ? 'auto' : 'smooth', + }); } focusTarget?.focus?.({ preventScroll: true }); return moved; @@ -115,25 +158,44 @@ export class RadioControls { _syncContextRadioLauncherState() { if (!this._contextRadioToggleBtn) return; const contextPanel = document.getElementById('global-context-panel'); - const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed')); + const contextExpanded = Boolean( + contextPanel && !contextPanel.classList.contains('collapsed'), + ); if (contextExpanded) { - const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed')); + const radioExpanded = Boolean( + this._radioPanel && !this._radioPanel.classList.contains('collapsed'), + ); this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded)); - const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context'; + this._contextRadioToggleBtn.setAttribute( + 'aria-expanded', + String(radioExpanded), + ); + const label = radioExpanded + ? 'Go to expanded Radio section' + : 'Expand Radio section in Context'; this._contextRadioToggleBtn.setAttribute('aria-label', label); this._contextRadioToggleBtn.title = label; return; } - const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open')); - this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen)); + const compactOpen = Boolean( + this._contextRadioDock?.classList.contains('disclosure-open'), + ); + this._contextRadioToggleBtn.setAttribute( + 'aria-controls', + 'context-radio-mini', + ); + this._contextRadioToggleBtn.setAttribute( + 'aria-expanded', + String(compactOpen), + ); const action = compactOpen ? 'Close' : 'Open'; - this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`); + this._contextRadioToggleBtn.setAttribute( + 'aria-label', + `${action} compact Radio controls`, + ); this._contextRadioToggleBtn.title = `${action} compact Radio controls`; } - destroy() { if (this.destroyed) return; this.destroyed = true; @@ -148,9 +210,16 @@ export class RadioControls { this._radioTunerPool = []; this._radioTunerStations = []; this._radioTunerBandPinnedForNavigation = false; - try { if (pointer !== null) this._radioTunerSlider?.releasePointerCapture(pointer); } catch { /* capture already released */ } + try { + if (pointer !== null) + this._radioTunerSlider?.releasePointerCapture(pointer); + } catch { + /* capture already released */ + } this._radioTuner?.classList.remove('is-dragging', 'is-static'); - document.getElementById('title-bar')?.classList.remove('radio-broadcasting'); + document + .getElementById('title-bar') + ?.classList.remove('radio-broadcasting'); this.radio.endTuning(); } } diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs index 89d128d40..5bbb88fef 100644 --- a/src/ui/radioControls.test.mjs +++ b/src/ui/radioControls.test.mjs @@ -13,13 +13,22 @@ class Element extends EventTarget { add: (...names) => names.forEach((n) => this.classes.add(n)), remove: (...names) => names.forEach((n) => this.classes.delete(n)), contains: (name) => this.classes.has(name), - toggle: (name, enabled) => enabled ? this.classes.add(name) : this.classes.delete(name), + toggle: (name, enabled) => + enabled ? this.classes.add(name) : this.classes.delete(name), }; } - setAttribute(name, value) { this.attrs.set(name, String(value)); } - getAttribute(name) { return this.attrs.get(name); } - querySelector() { return null; } - focus() { this.focusCount++; } + setAttribute(name, value) { + this.attrs.set(name, String(value)); + } + getAttribute(name) { + return this.attrs.get(name); + } + querySelector() { + return null; + } + focus() { + this.focusCount++; + } } function fixture() { @@ -34,25 +43,54 @@ function fixture() { const calls = []; const subscribers = []; const radio = { - subscribe(callback) { subscribers.push(callback); calls.push('subscribe'); return () => calls.push('unsubscribe'); }, - endTuning() { calls.push('endTuning'); }, - cycleStation(direction) { calls.push(['cycle', direction]); return true; }, + subscribe(callback) { + subscribers.push(callback); + calls.push('subscribe'); + return () => calls.push('unsubscribe'); + }, + endTuning() { + calls.push('endTuning'); + }, + cycleStation(direction) { + calls.push(['cycle', direction]); + return true; + }, }; const actions = { - isRegistered: () => true, isEnabled: () => false, + isRegistered: () => true, + isEnabled: () => false, runUserAction: (operation) => operation(), setEnabled: async () => true, setPanelCollapsed: (...args) => calls.push(['panel', ...args]), - layoutCockpit() {}, isCockpitActive: () => false, - scheduleLayout() {}, getLifecycle: () => null, + layoutCockpit() {}, + isCockpitActive: () => false, + scheduleLayout() {}, + getLifecycle: () => null, preservePanelStateDuringClear: () => false, }; const controls = new RadioControls({ - elements: { _radioPanel: panel, _radioEnableBtn: enable, _radioNextBtn: next }, - radio, actions, canvas: new Element(), + elements: { + _radioPanel: panel, + _radioEnableBtn: enable, + _radioNextBtn: next, + }, + radio, + actions, + canvas: new Element(), }); - return { controls, enable, next, actions, calls, subscribers, document, - cleanup() { controls.destroy(); Object.assign(globalThis, prior); } }; + return { + controls, + enable, + next, + actions, + calls, + subscribers, + document, + cleanup() { + controls.destroy(); + Object.assign(globalThis, prior); + }, + }; } test('Radio listeners and subscriptions are revoked once before tuning teardown', () => { @@ -61,7 +99,8 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown' f.controls.connect(); f.next.dispatchEvent(new Event('click')); assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]); - f.controls.destroy(); f.controls.destroy(); + f.controls.destroy(); + f.controls.destroy(); const snapshot = structuredClone(f.calls); f.next.dispatchEvent(new Event('click')); f.document.dispatchEvent(new Event('gev:radio-selected')); @@ -69,21 +108,28 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown' f.controls.connect(); assert.deepEqual(f.calls, snapshot); assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']); - } finally { f.cleanup(); } + } finally { + f.cleanup(); + } }); test('Radio reconnect removes its previous state subscription', () => { const f = fixture(); try { - f.controls.connect(); f.controls.connect(); + f.controls.connect(); + f.controls.connect(); assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']); - } finally { f.cleanup(); } + } finally { + f.cleanup(); + } }); test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => { const f = fixture(); let resolve; - const pending = new Promise((done) => { resolve = done; }); + const pending = new Promise((done) => { + resolve = done; + }); try { f.actions.setEnabled = () => pending; f.enable.dispatchEvent(new Event('click')); @@ -91,26 +137,36 @@ test('a delayed Radio enable cannot reveal or refocus controls after destruction f.controls.destroy(); const attrs = new Map(f.enable.attrs); resolve(true); - await pending; await new Promise((done) => setImmediate(done)); + await pending; + await new Promise((done) => setImmediate(done)); assert.deepEqual(f.enable.attrs, attrs); assert.equal(f.enable.focusCount, 0); - assert.equal(f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'), false); - } finally { f.cleanup(); } + assert.equal( + f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'), + false, + ); + } finally { + f.cleanup(); + } }); test('destruction releases an active tuner pointer after revoking listeners', () => { const f = fixture(); try { const released = []; - f.controls._radioTunerSlider = { releasePointerCapture(id) { - assert.equal(f.controls.listeners.signal.aborted, true); - released.push(id); - } }; + f.controls._radioTunerSlider = { + releasePointerCapture(id) { + assert.equal(f.controls.listeners.signal.aborted, true); + released.push(id); + }, + }; f.controls._radioTunerPointerId = 7; f.controls._radioTunerDragging = true; f.controls.destroy(); assert.deepEqual(released, [7]); assert.equal(f.controls._radioTunerDragging, false); assert.equal(f.controls._radioTunerPointerId, null); - } finally { f.cleanup(); } + } finally { + f.cleanup(); + } }); diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 46da1705b..eab9c3bf7 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -1,245 +1,367 @@ - /** Render Radio state without making playback or Context decisions. */ +/** Render Radio state without making playback or Context decisions. */ export function renderRadioState(state) { - if (this.destroyed || !state || !this._radioPanel) return; - const lifecycle = this.actions.getLifecycle() || null; - const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled'); - state = { - ...state, - enabled: lifecycle ? lifecycle.enabled : state.enabled, - lifecycleState, - lifecycleUncertain: lifecycle?.uncertain || false, - }; - this._radioState = state; - const enabled = Boolean(state.enabled); - const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling'; - const uncertain = Boolean(state.lifecycleUncertain); - const interactive = enabled && !transitioning && !uncertain; - const selected = state.selected || null; - const hasStations = state.filteredCount > 0; - const activePlayback = ['playing', 'buffering'].includes(state.audioState); - document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); - this._radioPanel.classList.toggle('radio-enabled', enabled); - this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain); - this._contextRadioDock?.classList.toggle('active', enabled); - if (this._contextRadioToggleBtn) { - this._contextRadioToggleBtn.classList.toggle('active', enabled); - } - this._syncContextRadioLauncherState(); - this._radioLayerState?.classList.toggle('active', enabled); - if (this._radioLayerState) { - this._radioLayerState.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF'))); - } - if (this._radioEnableBtn) { - this._radioEnableBtn.classList.toggle('active', enabled); - this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._radioEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._radioEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._radioEnableBtn.disabled = false; - this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._radioEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - if (this._contextRadioMiniEnableBtn) { - this._contextRadioMiniEnableBtn.classList.toggle('active', enabled); - this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._contextRadioMiniEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._contextRadioMiniEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._contextRadioMiniEnableBtn.disabled = false; - this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning)); - } - if (this._cockpitRadioEnableBtn) { - this._cockpitRadioEnableBtn.classList.toggle('active', enabled); - this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled)); - this._cockpitRadioEnableBtn.textContent = transitioning - ? lifecycleState.toUpperCase() - : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); - this._cockpitRadioEnableBtn.setAttribute( - 'aria-label', - uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, - ); - this._cockpitRadioEnableBtn.disabled = false; - this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning)); - this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning)); - } + if (this.destroyed || !state || !this._radioPanel) return; + const lifecycle = this.actions.getLifecycle() || null; + const lifecycleState = + lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled'); + state = { + ...state, + enabled: lifecycle ? lifecycle.enabled : state.enabled, + lifecycleState, + lifecycleUncertain: lifecycle?.uncertain || false, + }; + this._radioState = state; + const enabled = Boolean(state.enabled); + const transitioning = + lifecycleState === 'enabling' || lifecycleState === 'disabling'; + const uncertain = Boolean(state.lifecycleUncertain); + const interactive = enabled && !transitioning && !uncertain; + const selected = state.selected || null; + const hasStations = state.filteredCount > 0; + const activePlayback = ['playing', 'buffering'].includes(state.audioState); + document + .getElementById('title-bar') + ?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); + this._radioPanel.classList.toggle('radio-enabled', enabled); + this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain); + this._contextRadioDock?.classList.toggle('active', enabled); + if (this._contextRadioToggleBtn) { + this._contextRadioToggleBtn.classList.toggle('active', enabled); + } + this._syncContextRadioLauncherState(); + this._radioLayerState?.classList.toggle('active', enabled); + if (this._radioLayerState) { + this._radioLayerState.textContent = transitioning + ? lifecycleState.toUpperCase() + : uncertain + ? 'UNCERTAIN' + : state.loading + ? 'SYNC' + : enabled + ? `${state.filteredCount}/${state.stationCount}` + : 'OFF'; + } + if (this._radioEnableBtn) { + this._radioEnableBtn.classList.toggle('active', enabled); + this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); + this._radioEnableBtn.textContent = transitioning + ? lifecycleState.toUpperCase() + : uncertain + ? 'RECONCILE' + : enabled + ? 'DISABLE' + : 'ENABLE'; + this._radioEnableBtn.setAttribute( + 'aria-label', + uncertain + ? 'Reconcile Radio — lifecycle uncertain' + : `${enabled ? 'Disable' : 'Enable'} Radio`, + ); + this._radioEnableBtn.disabled = false; + this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning)); + this._radioEnableBtn.setAttribute('aria-busy', String(transitioning)); + } + if (this._contextRadioMiniEnableBtn) { + this._contextRadioMiniEnableBtn.classList.toggle('active', enabled); + this._contextRadioMiniEnableBtn.setAttribute( + 'aria-pressed', + String(enabled), + ); + this._contextRadioMiniEnableBtn.textContent = transitioning + ? lifecycleState.toUpperCase() + : uncertain + ? 'RECONCILE' + : enabled + ? 'DISABLE' + : 'ENABLE'; + this._contextRadioMiniEnableBtn.setAttribute( + 'aria-label', + uncertain + ? 'Reconcile Radio — lifecycle uncertain' + : `${enabled ? 'Disable' : 'Enable'} Radio`, + ); + this._contextRadioMiniEnableBtn.disabled = false; + this._contextRadioMiniEnableBtn.setAttribute( + 'aria-disabled', + String(transitioning), + ); + this._contextRadioMiniEnableBtn.setAttribute( + 'aria-busy', + String(transitioning), + ); + } + if (this._cockpitRadioEnableBtn) { + this._cockpitRadioEnableBtn.classList.toggle('active', enabled); + this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled)); + this._cockpitRadioEnableBtn.textContent = transitioning + ? lifecycleState.toUpperCase() + : uncertain + ? 'RECONCILE' + : enabled + ? 'DISABLE' + : 'ENABLE'; + this._cockpitRadioEnableBtn.setAttribute( + 'aria-label', + uncertain + ? 'Reconcile Radio — lifecycle uncertain' + : `${enabled ? 'Disable' : 'Enable'} Radio`, + ); + this._cockpitRadioEnableBtn.disabled = false; + this._cockpitRadioEnableBtn.setAttribute( + 'aria-disabled', + String(transitioning), + ); + this._cockpitRadioEnableBtn.setAttribute( + 'aria-busy', + String(transitioning), + ); + } - if (this._radioFilter) { - const prior = state.filter || 'all'; - const categorySignature = state.categories - .map((category) => `${category.id}:${category.count}:${category.color}`) - .join('|'); - if (categorySignature !== this._radioCategorySignature) { - this._radioFilter.replaceChildren(...state.categories.map((category) => { + if (this._radioFilter) { + const prior = state.filter || 'all'; + const categorySignature = state.categories + .map((category) => `${category.id}:${category.count}:${category.color}`) + .join('|'); + if (categorySignature !== this._radioCategorySignature) { + this._radioFilter.replaceChildren( + ...state.categories.map((category) => { const option = document.createElement('option'); option.value = category.id; option.textContent = `● ${category.label} (${category.count})`; option.dataset.radioColor = category.color; option.style.color = category.color; - option.setAttribute('aria-label', `${category.label} (${category.count})`); + option.setAttribute( + 'aria-label', + `${category.label} (${category.count})`, + ); return option; - })); - this._radioCategorySignature = categorySignature; - } - this._radioFilter.value = prior; - const activeCategory = state.categories.find((category) => category.id === prior); - this._radioFilter.style.color = activeCategory?.color || ''; - this._radioFilter.disabled = !interactive || !state.stationCount; + }), + ); + this._radioCategorySignature = categorySignature; } - - const tunerAvailable = interactive && state.filteredCount > 0; - if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; - if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; - if (this._radioTunerBandLabel) { - const activeCategory = state.categories.find((category) => category.id === state.filter); - this._radioTunerBandLabel.textContent = state.filter === 'all' - ? 'DIRECTORY BAND' - : `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`; - } - this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic)); - if (tunerAvailable) this._refreshRadioTunerBand?.(); - if (!tunerAvailable && this._radioTunerDragging) { - this._radioTunerDragging = false; - this._radioTunerDragSnapshot = null; - this._radioTunerStations = []; - this._radioTuner?.classList.remove('is-static', 'is-dragging'); - } - if (!tunerAvailable) { - this._radioTunerStations = []; - this._radioTunerPool = []; - this._radioTunerBandSignature = ''; - this._radioTunerSelectedId = null; - } - - if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED'; - if (this._radioStationMeta) { - const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : ''; - const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : ''; - this._radioStationMeta.textContent = selected - ? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only' - : (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.'); - } - if (this._radioStationTags) { - const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; - this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : ''; - } - if (this._radioStationHomepage) { - const homepage = selected?.homepage || ''; - this._radioStationHomepage.hidden = !homepage; - if (homepage) this._radioStationHomepage.href = homepage; - else this._radioStationHomepage.removeAttribute('href'); - } - - if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations; - if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations; - if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations; - if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations; - if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations; - if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; - if (this._radioPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._radioPlayBtn.disabled = !interactive || !hasStations; - this._radioPlayBtn.classList.toggle('active', activePlayback); - this._radioPlayBtn.textContent = action.toUpperCase(); - this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - } - if (this._contextRadioMiniPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations; - this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback); - this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; - this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - this._contextRadioMiniPlayBtn.title = action; - } - if (this._cockpitRadioPlayBtn) { - const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); - this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations; - this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback); - this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; - this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); - this._cockpitRadioPlayBtn.title = action; - } - if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped'; - if (this._radioVolume) this._radioVolume.disabled = !interactive; - if (this._radioVolume && document.activeElement !== this._radioVolume) { - this._radioVolume.value = String(Math.round(state.volume * 100)); - if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) { - this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100)); - } - if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive; - if (this._contextRadioMiniVolumeValue) { - this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) { - this._cockpitRadioVolume.value = String(Math.round(state.volume * 100)); - } - if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive; - if (this._cockpitRadioVolumeValue) { - this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; - } - if (this._contextRadioMiniStation) { - this._contextRadioMiniStation.textContent = uncertain - ? 'RADIO STATE UNCERTAIN' - : (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY')); - } - if (this._cockpitRadioStation) { - this._cockpitRadioStation.textContent = uncertain - ? 'UNCERTAIN' - : (selected?.name || (state.loading ? 'SYNCING' : 'READY')); - } - if (this._radioPlaybackState) { - const catalogSuffix = state.degraded - ? (state.stale ? ' · stale/degraded directory' : ' · degraded directory') - : (state.stale ? ' · stale directory' : ''); - const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : ''; - const messages = { - stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off', - loading: 'Connecting directly to broadcaster…', - buffering: 'Buffering broadcaster stream…', - playing: `Playing ${selected?.name || 'station'}`, - paused: `Paused ${selected?.name || 'station'}`, - error: state.audioError || 'Broadcaster stream unavailable', - }; - const voiceSuffix = state.voiceDucked - ? ' · muted during voice interaction' - : (state.voiceRestoring ? ' · restoring volume after voice' : ''); - const tuningSuffix = state.tuningAwaitingStationId - ? (state.audioState === 'error' - ? ' · static indicates no broadcaster audio' - : ' · tuning static until broadcaster starts') - : ''; - const unavailable = state.tuningUnavailableStationId - ? 'Station unavailable after directory refresh — choose another channel' - : null; - const lifecycleMessage = transitioning - ? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…') - : null; - const uncertainMessage = uncertain - ? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile' - : null; - this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; - this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error')); - } - if ( - !enabled - && !transitioning - && !this.actions.preservePanelStateDuringClear() - && !this._radioPanel.classList.contains('collapsed') - ) { - this.actions.setPanelCollapsed('radio-panel', true); - } - this.actions.scheduleLayout(); + this._radioFilter.value = prior; + const activeCategory = state.categories.find( + (category) => category.id === prior, + ); + this._radioFilter.style.color = activeCategory?.color || ''; + this._radioFilter.disabled = !interactive || !state.stationCount; } + const tunerAvailable = interactive && state.filteredCount > 0; + if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; + if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; + if (this._radioTunerBandLabel) { + const activeCategory = state.categories.find( + (category) => category.id === state.filter, + ); + this._radioTunerBandLabel.textContent = + state.filter === 'all' + ? 'DIRECTORY BAND' + : `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`; + } + this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic)); + if (tunerAvailable) this._refreshRadioTunerBand?.(); + if (!tunerAvailable && this._radioTunerDragging) { + this._radioTunerDragging = false; + this._radioTunerDragSnapshot = null; + this._radioTunerStations = []; + this._radioTuner?.classList.remove('is-static', 'is-dragging'); + } + if (!tunerAvailable) { + this._radioTunerStations = []; + this._radioTunerPool = []; + this._radioTunerBandSignature = ''; + this._radioTunerSelectedId = null; + } + + if (this._radioStationName) + this._radioStationName.textContent = + selected?.name || 'NO STATION SELECTED'; + if (this._radioStationMeta) { + const place = selected + ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') + : ''; + const signal = selected + ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''] + .filter(Boolean) + .join(' · ') + : ''; + this._radioStationMeta.textContent = selected + ? [place, signal].filter(Boolean).join(' / ') || + 'Directory metadata only' + : state.loading + ? 'Loading station directory…' + : 'Choose a globe marker or use next.'; + } + if (this._radioStationTags) { + const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; + this._radioStationTags.textContent = tags.length + ? `TAGS · ${tags.join(' · ')}` + : ''; + } + if (this._radioStationHomepage) { + const homepage = selected?.homepage || ''; + this._radioStationHomepage.hidden = !homepage; + if (homepage) this._radioStationHomepage.href = homepage; + else this._radioStationHomepage.removeAttribute('href'); + } + + if (this._radioPrevBtn) + this._radioPrevBtn.disabled = !interactive || !hasStations; + if (this._radioNextBtn) + this._radioNextBtn.disabled = !interactive || !hasStations; + if (this._contextRadioMiniPrevBtn) + this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations; + if (this._contextRadioMiniNextBtn) + this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations; + if (this._cockpitRadioPrevBtn) + this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations; + if (this._cockpitRadioNextBtn) + this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; + if (this._radioPlayBtn) { + const action = activePlayback + ? 'Pause' + : state.audioState === 'paused' + ? 'Resume' + : 'Play'; + this._radioPlayBtn.disabled = !interactive || !hasStations; + this._radioPlayBtn.classList.toggle('active', activePlayback); + this._radioPlayBtn.textContent = action.toUpperCase(); + this._radioPlayBtn.setAttribute( + 'aria-label', + `${action} ${selected ? 'selected' : 'nearest'} radio station`, + ); + } + if (this._contextRadioMiniPlayBtn) { + const action = activePlayback + ? 'Pause' + : state.audioState === 'paused' + ? 'Resume' + : 'Play'; + this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations; + this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback); + this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; + this._contextRadioMiniPlayBtn.setAttribute( + 'aria-label', + `${action} ${selected ? 'selected' : 'nearest'} radio station`, + ); + this._contextRadioMiniPlayBtn.title = action; + } + if (this._cockpitRadioPlayBtn) { + const action = activePlayback + ? 'Pause' + : state.audioState === 'paused' + ? 'Resume' + : 'Play'; + this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations; + this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback); + this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; + this._cockpitRadioPlayBtn.setAttribute( + 'aria-label', + `${action} ${selected ? 'selected' : 'nearest'} radio station`, + ); + this._cockpitRadioPlayBtn.title = action; + } + if (this._radioStopBtn) + this._radioStopBtn.disabled = + !interactive || state.audioState === 'stopped'; + if (this._radioVolume) this._radioVolume.disabled = !interactive; + if (this._radioVolume && document.activeElement !== this._radioVolume) { + this._radioVolume.value = String(Math.round(state.volume * 100)); + if (this._radioVolumeValue) + this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; + } + if ( + this._contextRadioMiniVolume && + document.activeElement !== this._contextRadioMiniVolume + ) { + this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100)); + } + if (this._contextRadioMiniVolume) + this._contextRadioMiniVolume.disabled = !interactive; + if (this._contextRadioMiniVolumeValue) { + this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; + } + if ( + this._cockpitRadioVolume && + document.activeElement !== this._cockpitRadioVolume + ) { + this._cockpitRadioVolume.value = String(Math.round(state.volume * 100)); + } + if (this._cockpitRadioVolume) + this._cockpitRadioVolume.disabled = !interactive; + if (this._cockpitRadioVolumeValue) { + this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; + } + if (this._contextRadioMiniStation) { + this._contextRadioMiniStation.textContent = uncertain + ? 'RADIO STATE UNCERTAIN' + : selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY'); + } + if (this._cockpitRadioStation) { + this._cockpitRadioStation.textContent = uncertain + ? 'UNCERTAIN' + : selected?.name || (state.loading ? 'SYNCING' : 'READY'); + } + if (this._radioPlaybackState) { + const catalogSuffix = state.degraded + ? state.stale + ? ' · stale/degraded directory' + : ' · degraded directory' + : state.stale + ? ' · stale directory' + : ''; + const outsideFilter = + selected && state.selectedIndex < 0 ? ' · outside current filter' : ''; + const messages = { + stopped: enabled + ? 'Ready — playback starts only from your action' + : 'Radio off', + loading: 'Connecting directly to broadcaster…', + buffering: 'Buffering broadcaster stream…', + playing: `Playing ${selected?.name || 'station'}`, + paused: `Paused ${selected?.name || 'station'}`, + error: state.audioError || 'Broadcaster stream unavailable', + }; + const voiceSuffix = state.voiceDucked + ? ' · muted during voice interaction' + : state.voiceRestoring + ? ' · restoring volume after voice' + : ''; + const tuningSuffix = state.tuningAwaitingStationId + ? state.audioState === 'error' + ? ' · static indicates no broadcaster audio' + : ' · tuning static until broadcaster starts' + : ''; + const unavailable = state.tuningUnavailableStationId + ? 'Station unavailable after directory refresh — choose another channel' + : null; + const lifecycleMessage = transitioning + ? lifecycleState === 'enabling' + ? 'Radio is enabling…' + : 'Radio is disabling…' + : null; + const uncertainMessage = uncertain + ? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile' + : null; + this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; + this._radioPlaybackState.classList.toggle( + 'error', + Boolean( + uncertainMessage || + unavailable || + state.error || + state.audioState === 'error', + ), + ); + } + if ( + !enabled && + !transitioning && + !this.actions.preservePanelStateDuringClear() && + !this._radioPanel.classList.contains('collapsed') + ) { + this.actions.setPanelCollapsed('radio-panel', true); + } + this.actions.scheduleLayout(); +} diff --git a/src/ui/radioTunerModel.js b/src/ui/radioTunerModel.js index 5d328371f..8c84422f9 100644 --- a/src/ui/radioTunerModel.js +++ b/src/ui/radioTunerModel.js @@ -1,7 +1,15 @@ /** Map an integer tuner slot directly to one available directory station. */ export function radioTunerSlot(value, stationCount) { const count = Math.max(0, Math.floor(Number(stationCount) || 0)); - if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 }; + if (!count) + return { + slot: 0, + max: 0, + locked: false, + stationIndex: -1, + leftIndex: -1, + rightIndex: -1, + }; const max = Math.max(0, count - 1); const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0))); return { @@ -20,29 +28,49 @@ export function radioTunerCommitSlot(value, stationCount) { } /** Map one pointer coordinate to continuous absolute directory progress. */ -export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) { +export function radioTunerPointerPosition( + clientX, + left, + width, + stationCount, + insetPx = 7, +) { const count = Math.max(0, Math.floor(Number(stationCount) || 0)); if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 }; if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 }; const inset = Math.max(0, Number(insetPx) || 0); const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2); - const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth)); + const ratio = Math.min( + 1, + Math.max( + 0, + ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth, + ), + ); const coordinate = ratio * (count - 1); return { ratio, coordinate, - stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))), + stationIndex: Math.min( + count - 1, + Math.max(0, Math.floor(coordinate + 0.5)), + ), }; } /** Build a bounded virtual tuner tape around one continuous directory coordinate. */ -export function buildRadioTunerTicks(coordinate, stationCount, width, { - insetPx = 7, - minPitchPx = 14, - speedFactor = 5, - overscan = 2, - labelStep = 6, -} = {}) { +export function buildRadioTunerTicks( + coordinate, + stationCount, + width, + { + insetPx = 7, + minPitchPx = 14, + speedFactor = 5, + overscan = 2, + labelStep = 6, + } = {}, +) { const count = Math.max(0, Math.floor(Number(stationCount) || 0)); const dialWidth = Math.max(0, Number(width) || 0); const inset = Math.max(0, Number(insetPx) || 0); @@ -51,7 +79,15 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, { const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0)); if (count === 1) { return { - ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }], + ticks: [ + { + stationIndex: 0, + channel: 1, + xPx: inset + usableWidth / 2, + current: true, + label: '01', + }, + ], needleX: inset + usableWidth / 2, pitchPx: Math.max(1, Number(minPitchPx) || 14), ratio: 0.5, @@ -64,16 +100,29 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, { ); const needleX = inset + directoryStep * value; const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0); - const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx)); - const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx)); - const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5))); + const first = Math.max( + 0, + Math.ceil(value + (-overscanPx - needleX) / pitchPx), + ); + const last = Math.min( + count - 1, + Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx), + ); + const currentIndex = Math.min( + count - 1, + Math.max(0, Math.floor(value + 0.5)), + ); const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6)); const labelWidth = Math.max(2, String(count).length); const ticks = []; for (let stationIndex = first; stationIndex <= last; stationIndex += 1) { const channel = stationIndex + 1; const current = stationIndex === currentIndex; - const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0; + const labelled = + current || + stationIndex === 0 || + stationIndex === count - 1 || + channel % majorEvery === 0; ticks.push({ stationIndex, channel, @@ -84,4 +133,3 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, { } return { ticks, needleX, pitchPx, ratio: value / (count - 1) }; } - From 02e90ca698b7e64e2410adc371cddbe0d4ee5a71 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:25:09 -0700 Subject: [PATCH 24/92] test: keep Radio invariants independent of formatting --- src/cockpitMarkup.test.mjs | 10 +++++----- src/contextSessionOrdering.test.mjs | 2 +- src/firstRunExperience.test.mjs | 2 +- src/panelEscape.test.mjs | 2 +- src/radioMarkup.test.mjs | 10 +++++----- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 355dfc040..dc3201310 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -355,12 +355,12 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => { ); assert.match( radioBindings, - /!displayOpen && !radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/, + /!displayOpen\s*&&\s*!radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/, 'Live Signals should reopen only after both utility panels close and no manual collapse is retained', ); assert.match( radioBindings, - /event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/, + /event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\)\s*return;[\s\S]*?setCockpitDisclosure\('display', false\);/, 'left-side interactions must not collapse the independent Cockpit utilities', ); }); @@ -503,18 +503,18 @@ test('Location navigation releases immediate routes before flight and deferred r }); test('Cockpit Radio station changes preserve first-person camera ownership', () => { - const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/); + const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/); assert.ok(cycleHelper, 'shared Radio cycle helper is missing'); assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/); assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/); assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/); assert.match( radioBindings, - /listen\(this\._cockpitRadioPrevBtn, 'click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/, + /listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/, ); assert.match( radioBindings, - /listen\(this\._cockpitRadioNextBtn, 'click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/, + /listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/, ); }); diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index e9f8ef8d7..4bc5f6704 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -192,7 +192,7 @@ test('only the expanded Radio Enable gesture requests the contained post-enable radioBindings.indexOf("this.listen(this._radioFilter, 'change'"), ); assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/); - assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/); + assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/); assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1); assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/); }); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index f127e88b5..b19f082ae 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -270,7 +270,7 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh // disclosure's earlier capture handler closed the disclosure and the launcher // dismissed itself off the same key. The earlier listener is the only one that // can stop the later one — and only the immediate form does it. - const radioEsc = ui.slice(ui.indexOf("if (event.key !== 'Escape' || !this._contextRadioDock")); + const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||")); const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false')); assert.match(claim, /event\.preventDefault\(\);/); assert.match(claim, /event\.stopImmediatePropagation\(\);/); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index 6b44052fc..c44be1b97 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -160,7 +160,7 @@ test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => { assert.match( radioBindings, - /const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, + /const nestedPanel = event\.target\?\.closest\?\.\(\s*'\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)',?\s*\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, ); assert.match( radioBindings, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 91a86b465..303a54246 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -259,9 +259,9 @@ test('Radio is nested inside Context with separate disclosure and power controls const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart)); assert.doesNotMatch(cycleMethod, /refreshTunerBand/); assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/); - assert.match(radioBindings, /listen\(this\.canvas, 'pointerdown', releaseNavigationBand/); + assert.match(radioBindings, /listen\(\s*this\.canvas,\s*'pointerdown',\s*releaseNavigationBand/); assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/); - assert.match(radioBindings, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/); + assert.match(radioBindings, /tunerPreview\(\{\s*coordinate: this\._radioTunerCoordinate,\s*rotate: commit,?\s*\}\)/); assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/); assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/); assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/); @@ -312,7 +312,7 @@ test('expanded Context routes its Radio icon to the embedded section and keeps c const revealMethod = radioControlsSource.slice(revealStart, revealEnd); assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing'); assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/); - assert.match(revealMethod, /scroller\.scrollTo\(\{ top: next, behavior: reducedMotion \? 'auto' : 'smooth' \}\)/); + assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/); assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/); assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/); @@ -320,8 +320,8 @@ test('expanded Context routes its Radio icon to the embedded section and keeps c const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10); const syncMethod = radioControlsSource.slice(syncStart, syncEnd); assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing'); - assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded', String\(radioExpanded\)/); - assert.match(syncMethod, /aria-controls', 'context-radio-mini'[\s\S]*?aria-expanded', String\(compactOpen\)/); + assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/); + assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/); const renderMethod = radioPresentation; assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/); assert.doesNotMatch(renderMethod, /compact Radio controls/); From 641a2c67e2705faab875735b1bdf4ef5ad2280ee Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:30:23 -0700 Subject: [PATCH 25/92] test: align Radio browser rendering with platform QA defaults --- scripts/qa-radio.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index bcc5524d1..48920dd8a 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -87,7 +87,10 @@ async function main() { headless: HEADFUL ? false : 'new', ...(chrome ? { executablePath: chrome } : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', + '--no-sandbox', '--disable-setuid-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], From 53c510fe9f0f933a9998bf76f174e24c89784c08 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:40:51 -0700 Subject: [PATCH 26/92] fix: keep disposed Radio disclosures inert --- src/ui/radioBindings.js | 2 ++ src/ui/radioControls.js | 2 +- src/ui/radioControls.test.mjs | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 530e4b43e..68ee78fa8 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -10,6 +10,7 @@ export function bindRadioControls() { if (!this._radioPanel) return; const tunerListenerOptions = {}; const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { + if (this.destroyed) return; const open = Boolean(expanded); this._contextRadioDock?.classList.toggle('disclosure-open', open); if (this._contextRadioMini) this._contextRadioMini.hidden = !open; @@ -23,6 +24,7 @@ export function bindRadioControls() { expanded, { returnFocus = false } = {}, ) => { + if (this.destroyed) return; const displayOpen = kind === 'display' && Boolean(expanded); const radioOpen = kind === 'radio' && Boolean(expanded); if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true); diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index df2cc3b2b..8a60898e2 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -156,7 +156,7 @@ export class RadioControls { /** Keep the Context header Radio shortcut truthful for its current route. */ _syncContextRadioLauncherState() { - if (!this._contextRadioToggleBtn) return; + if (this.destroyed || !this._contextRadioToggleBtn) return; const contextPanel = document.getElementById('global-context-panel'); const contextExpanded = Boolean( contextPanel && !contextPanel.classList.contains('collapsed'), diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs index 5bbb88fef..b99c96a18 100644 --- a/src/ui/radioControls.test.mjs +++ b/src/ui/radioControls.test.mjs @@ -102,6 +102,8 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown' f.controls.destroy(); f.controls.destroy(); const snapshot = structuredClone(f.calls); + f.controls._setRadioDisclosure(true); + f.controls._setCockpitDisclosure('display', true); f.next.dispatchEvent(new Event('click')); f.document.dispatchEvent(new Event('gev:radio-selected')); f.subscribers[0]({ enabled: true }); From 11aa90c7bbe03ab7b1d8e60311978ea43588e917 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:40:51 -0700 Subject: [PATCH 27/92] test: isolate Radio gestures and wait for contained reveal --- scripts/qa-radio.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 48920dd8a..310367cbc 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -25,6 +25,10 @@ const option = (name, fallback) => { }; const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173'); const APP_ORIGIN = new URL(APP_URL).origin; +// Radio owns this harness's keyboard and pointer actions; the welcome dialog +// has its own acceptance harness and must not intercept those gestures. +const RADIO_URL = new URL(APP_URL); +RADIO_URL.searchParams.set('welcome', '0'); const HEADFUL = args.includes('--headful'); const chromeCandidates = [ @@ -216,7 +220,7 @@ async function main() { } }); - await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 }); await page.waitForFunction( @@ -574,7 +578,14 @@ async function main() { await page.evaluate(() => window.__qaReleaseRadioEnable?.()); await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio')); await page.evaluate(() => window.__qaRestoreRadioEnable?.()); - await sleep(650); + await page.waitForFunction(() => { + const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); + const viewport = scroller.getBoundingClientRect(); + const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect(); + const play = document.getElementById('radio-play-btn').getBoundingClientRect(); + return directory.top >= viewport.top && directory.bottom <= viewport.bottom + && play.top >= viewport.top && play.bottom <= viewport.bottom; + }, { timeout: 10_000 }); const explicitRevealAfter = await page.evaluate(() => { const gev = window.__godsEyeView; const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); From b119aef0ad9ac22dcb985f36b034e18261985283 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:49:26 -0700 Subject: [PATCH 28/92] test: settle Radio render frames before native picking --- scripts/qa-radio.mjs | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 310367cbc..e94e22898 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -80,6 +80,22 @@ function check(name, ok, detail = '') { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// Native pick buffers and clustered overlay entries settle on rendered frames, +// not on wall-clock sleeps, particularly with software rendering. +async function settleRadioFrames(page) { + await page.evaluate(() => new Promise((resolve, reject) => { + const scene = window.__godsEyeView.viewer.scene; + let remaining = 4; + const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000); + const remove = scene.postRender.addEventListener(() => { + if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); } + else requestAnimationFrame(() => scene.requestRender()); + }); + scene.requestRender(); + })); +} + + async function main() { const response = await fetch(APP_URL).catch(() => null); if (!response?.ok) { @@ -652,6 +668,7 @@ async function main() { viewer.scene.requestRender(); }, { lon: spec.lon, height: view.height }); await sleep(700); + await settleRadioFrames(page); const sample = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; @@ -1334,12 +1351,9 @@ async function main() { && clusterBadge?.rect?.h > 0, JSON.stringify(clusterBadge), ); - const highGlobalClusterLabels = await page.evaluate(async () => { + await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; - const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const ellipsoid = viewer.scene.globe.ellipsoid; - const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); - const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); viewer.camera.cancelFlight(); viewer.camera.setView({ destination: ellipsoid.cartographicToCartesian({ @@ -1351,6 +1365,14 @@ async function main() { }); viewer.scene.requestRender(); await new Promise((resolve) => setTimeout(resolve, 500)); + }); + await settleRadioFrames(page); + const highGlobalClusterLabels = await page.evaluate(async () => { + const viewer = window.__godsEyeView.viewer; + const radio = window.__godsEyeView.dataManager.layers.get('radio').module; + const ellipsoid = viewer.scene.globe.ellipsoid; + const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); const source = radio.getOverlayDiagnostics(); const cameraPosition = viewer.camera.positionWC; const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z); @@ -1361,11 +1383,23 @@ async function main() { const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection; let clusterPoint = null; + // Pool order is not visibility order after a camera change. Choose an + // on-screen point independently of the native pick result being tested. + const visiblePoints = []; for (let index = 0; index < (points?.length || 0); index += 1) { const point = points.get(index); if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue; const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position); if (!anchor) continue; + if (anchor.x < 0 || anchor.y < 0 || anchor.x > viewer.canvas.clientWidth || anchor.y > viewer.canvas.clientHeight) continue; + visiblePoints.push({ point, anchor }); + } + visiblePoints.sort((a, b) => ( + Math.hypot(a.anchor.x - viewer.canvas.clientWidth / 2, a.anchor.y - viewer.canvas.clientHeight / 2) + - Math.hypot(b.anchor.x - viewer.canvas.clientWidth / 2, b.anchor.y - viewer.canvas.clientHeight / 2) + )); + if (visiblePoints.length) { + const { point, anchor } = visiblePoints[0]; const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => ( picked?.primitive === point && picked?.id === point.id )); @@ -1373,7 +1407,6 @@ async function main() { maxDistance: point.distanceDisplayCondition?.far ?? null, pickable: Boolean(exactPick), }; - break; } return { source, From a5af1cbfa332912f0fdf35e18997a0dd116ed178 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:53:10 -0700 Subject: [PATCH 29/92] fix: restore independent UI observer and subscription teardown --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 6 ++ scripts/format-scope.json | 3 +- scripts/qa-ui-disposal.mjs | 123 +++++++++++++++++++++++++++++++++++++ src/ui.js | 10 +++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-ui-disposal.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bddf56bb..a83bbbe08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction. + - Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup. - Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc911cb8d..cb2616ef0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +## UI disposal + +UI disposal releases the CCTV subscription, command-dock observer, legacy drag +resize observer and window resize listener. These remain independent of the +Location component and are included in whole-UI browser teardown acceptance. + ## Radio controls Radio panel, compact controls and tuner presentation have a dedicated owner. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5e0a8b660..91918223e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -172,5 +172,6 @@ "src/ui/radioBindings.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", - "src/ui/radioControls.test.mjs" + "src/ui/radioControls.test.mjs", + "scripts/qa-ui-disposal.mjs" ] diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs new file mode 100644 index 000000000..155d9116a --- /dev/null +++ b/scripts/qa-ui-disposal.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** Exercise the real UI disposal path, including unrelated resource owners. */ +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; +function check(name, ok) { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +} +try { + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._dataManager && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async () => { + const ui = window.__godsEyeView.styleManager; + const counts = {}; + const watchObserver = (name) => { + const observer = ui[name]; + if (!observer) return false; + const disconnect = observer.disconnect.bind(observer); + counts[name] = 0; + observer.disconnect = () => { + counts[name]++; + return disconnect(); + }; + return true; + }; + const observed = [ + '_commandDockTrayObserver', + '_draggableResizeObserver', + '_leftStackResizeObserver', + '_rightStackResizeObserver', + '_leftStackMutationObserver', + '_rightStackMutationObserver', + ].filter(watchObserver); + const cctvUnsubscribe = ui._cctvUnsubscribe; + counts.cctv = 0; + if (cctvUnsubscribe) + ui._cctvUnsubscribe = () => { + counts.cctv++; + return cctvUnsubscribe(); + }; + const resizeHandler = ui._windowResizeHandler; + const removeEventListener = window.removeEventListener; + counts.resize = 0; + window.removeEventListener = function (type, callback, options) { + if (type === 'resize' && callback === resizeHandler) counts.resize++; + return removeEventListener.call(this, type, callback, options); + }; + try { + await ui.dispose(); + const once = JSON.stringify(counts); + await ui.dispose(); + window.dispatchEvent(new Event('resize')); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return { + connected: Boolean( + cctvUnsubscribe && + resizeHandler && + observed.includes('_commandDockTrayObserver'), + ), + observersReleased: observed.every( + (name) => counts[name] === 1 && ui[name] === null, + ), + subscriptionReleased: counts.cctv === 1 && ui._cctvUnsubscribe === null, + resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null, + controlsReleased: + ui._radioControls.destroyed && ui._locationControls.destroyed, + idempotent: once === JSON.stringify(counts), + }; + } finally { + window.removeEventListener = removeEventListener; + } + }); + check( + 'real UI has the expected live resources before disposal', + result.connected, + ); + check( + 'UI disposal disconnects each active panel observer once', + result.observersReleased, + ); + check( + 'UI disposal releases the CCTV state subscription', + result.subscriptionReleased, + ); + check( + 'UI disposal removes the registered window resize handler', + result.resizeReleased, + ); + check( + 'Radio and Location controls are disposed with the UI', + result.controlsReleased, + ); + check('repeated UI disposal is inert', result.idempotent); + check( + 'disposal and subsequent resize produce no uncaught browser errors', + errors.length === 0, + ); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/src/ui.js b/src/ui.js index 634c0e5b3..d8bf096bc 100644 --- a/src/ui.js +++ b/src/ui.js @@ -8594,6 +8594,16 @@ export class StyleManager { this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; + this._cctvUnsubscribe?.(); + this._cctvUnsubscribe = null; + this._commandDockTrayObserver?.disconnect?.(); + this._commandDockTrayObserver = null; + this._draggableResizeObserver?.disconnect(); + this._draggableResizeObserver = null; + if (this._windowResizeHandler) { + window.removeEventListener('resize', this._windowResizeHandler); + this._windowResizeHandler = null; + } if (this._loadingVisibilityHandler) { document.removeEventListener('visibilitychange', this._loadingVisibilityHandler); this._loadingVisibilityHandler = null; From 457fe5451c23a38a17ab2fe86b013e02bef0292a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 00:05:16 -0700 Subject: [PATCH 30/92] test: follow hold-Space and cancellable tuner keyboard semantics --- scripts/qa-radio.mjs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index e94e22898..10365afeb 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2699,9 +2699,15 @@ async function main() { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; await radio.play(); const beforeSpace = radio.getUIState().audioState; - document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, code: 'Space', key: ' ' })); + // Simulate an already-started hold-Space session; click-started open mic + // deliberately ignores Space takeover, and short taps never claim voice. + const priorPushToTalkMode = voice.pushToTalkMode; + voice.pushToTalkMode = true; + document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); + await new Promise((resolve) => setTimeout(resolve, 700)); const afterSpace = radio.getUIState().audioState; - document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' })); + document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); + voice.pushToTalkMode = priorPushToTalkMode; await radio.play(); voice.setVoiceSpeaker('ai'); return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState }; @@ -3369,6 +3375,7 @@ async function main() { const slider = document.getElementById('radio-tuner-slider'); const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, { bubbles: true, + cancelable: true, key: value, })); const markerId = () => gev.viewer.entities.values @@ -3423,7 +3430,8 @@ async function main() { previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)), previewAndCancel('PageDown', Math.max(0, base - pageStep)), ]; - return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation }; + return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation, + panelStayedOpen: !document.getElementById('radio-panel').classList.contains('collapsed') }; }); check( 'keyboard preview is silent, key release commits once, and Escape restores the committed station', @@ -3435,7 +3443,8 @@ async function main() { && !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1 && tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId && tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex - && !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0, + && !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0 + && tunerKeyboard.panelStayedOpen, JSON.stringify(tunerKeyboard), ); check( @@ -4437,7 +4446,10 @@ async function main() { }); await page.click('#context-radio-toggle-btn'); await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed')); - await sleep(450); + await page.waitForFunction((priorScroll) => ( + document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel' + && document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll + ), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop); const expandedContextRadioAfter = await page.evaluate(() => { const gev = window.__godsEyeView; gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState()); From a9e7cb67015498369e2d2402debc8e865811de30 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 00:21:51 -0700 Subject: [PATCH 31/92] test: measure Cockpit layout after rendered frames --- scripts/qa-radio.mjs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 10365afeb..a232bfdeb 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1922,7 +1922,10 @@ async function main() { hudVariant: manager.hud.getVariant(), focusId: document.activeElement?.id || null, }; - const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320)); + const waitForLayout = async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; const cases = []; manager.setPanelCollapsed('global-context-panel', true); @@ -1999,7 +2002,10 @@ async function main() { // owns the real tracked-aircraft camera session. Hold the frame update so // the intentionally synthetic Cockpit shell is not auto-exited mid-check. const realCockpitUpdate = manager.cockpitView.update; - const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320)); + const waitForLayout = async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; const prior = { cockpit: document.body.classList.contains('cockpit-mode'), cockpitActive: manager.cockpitView.active, @@ -2104,7 +2110,10 @@ async function main() { }; // The Intel HUD fades over 400ms and keeps its readout rects for the // whole transition, so both lanes are measured only once it has settled. - const waitForHudSettle = () => new Promise((resolve) => setTimeout(resolve, 560)); + const waitForHudSettle = async () => { + await new Promise((resolve) => setTimeout(resolve, 560)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; for (let index = 0; index < 5; index += 1) { recordLayoutStep(); if (index < 4) { @@ -2255,14 +2264,17 @@ async function main() { const stableTop = utility.getBoundingClientRect().top; const transitionTops = []; let sampling = true; + let sampleFrame = null; const sampleTop = () => { + if (!sampling) return; transitionTops.push(utility.getBoundingClientRect().top); - if (sampling) requestAnimationFrame(sampleTop); + sampleFrame = requestAnimationFrame(sampleTop); }; - requestAnimationFrame(sampleTop); + sampleFrame = requestAnimationFrame(sampleTop); await manager._setMapStack('osm', { syncShare: false }); await waitForLayout(); sampling = false; + cancelAnimationFrame(sampleFrame); mapProviderUtilityStable = transitionTops.length > 1 && transitionTops.every((top) => Math.abs(top - stableTop) < 1); } From f45f399c314b94ff2f513db32cc1778e09e76b4c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 00:38:01 -0700 Subject: [PATCH 32/92] refactor: isolate camera panel controls and presentation --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 7 + docs/CURRENT-STATE.md | 11 + package.json | 3 +- scripts/format-scope.json | 9 +- scripts/package-boundaries.json | 12 + scripts/qa-cctv-v2.mjs | 4 +- scripts/qa-ui-disposal.mjs | 10 +- src/cctvFocusPolicy.test.mjs | 22 + src/ui.js | 696 +++----------------------------- src/ui/cctv.js | 1 + src/ui/cctvBindings.js | 130 ++++++ src/ui/cctvCalibration.js | 140 +++++++ src/ui/cctvControls.js | 66 +++ src/ui/cctvControls.test.mjs | 162 ++++++++ src/ui/cctvFrames.js | 102 +++++ src/ui/cctvPresentation.js | 212 ++++++++++ 17 files changed, 932 insertions(+), 657 deletions(-) create mode 100644 src/ui/cctv.js create mode 100644 src/ui/cctvBindings.js create mode 100644 src/ui/cctvCalibration.js create mode 100644 src/ui/cctvControls.js create mode 100644 src/ui/cctvControls.test.mjs create mode 100644 src/ui/cctvFrames.js create mode 100644 src/ui/cctvPresentation.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a83bbbe08..9137393db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal. + - Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction. - Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index f11735a43..4e705a627 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -295,3 +295,10 @@ receives the existing Radio port and explicit layer/layout actions, without importing the renderer or station providers. Pure tuner calculations retain compatibility exports from the data layer. Disposal revokes DOM listeners and subscriptions before ending the active tuning interaction. + +### Camera panel controls + +`ui/cctv` composes camera controls, frame loading, calibration editing and status +presentation. It receives DOM elements, the existing camera port and explicit +application actions; it imports no provider, layer or camera engine. Selection, +placement, navigation and storage policy remain outside the component family. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cb2616ef0..fcce42e0e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,16 @@ # God's Eye View Current State +## Camera panel ownership + +CCTV controls receive the existing camera port and explicit application actions. +Frame loading, calibration editing and status display have separate modules; +providers, camera placement and navigation policy retain their existing owners. +Changing cameras invalidates old image callbacks and cancels an unfinished +calibration edit. Failed refreshes preserve settled pixels for the same camera, +while a newly selected camera never shows the prior camera's image. Disposal +releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. + + ## UI disposal UI disposal releases the CCTV subscription, command-dock observer, legacy drag diff --git a/package.json b/package.json index c88533b76..62c51ed5e 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "./ui/layers": "./src/ui/layers.js", "./ui/layers/feedback": "./src/loadingFeedback.js", "./ui/location": "./src/ui/location.js", - "./ui/radio": "./src/ui/radio.js" + "./ui/radio": "./src/ui/radio.js", + "./ui/cctv": "./src/ui/cctv.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 91918223e..c523193b8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -173,5 +173,12 @@ "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", "src/ui/radioControls.test.mjs", - "scripts/qa-ui-disposal.mjs" + "scripts/qa-ui-disposal.mjs", + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvControls.test.mjs", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b1435a095..83d976631 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -357,5 +357,17 @@ "src/ui/radioTunerModel.js" ], "external": [] + }, + "cctv-controls": { + "exports": ["./ui/cctv"], + "modules": [ + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js" + ], + "external": [] } } diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 0b636f21b..bfb15f8ca 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -94,7 +94,9 @@ const getOpt = (name, dflt) => { const getFlag = (name) => argv.includes(name); const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; -const APP_URL = getOpt('--url', BASE_URL); +const CCTV_URL = new URL(getOpt('--url', BASE_URL)); +CCTV_URL.searchParams.set('welcome', '0'); +const APP_URL = CCTV_URL.href; const HEADFUL = getFlag('--headful'); const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2'); diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 155d9116a..1cfa077c4 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -51,10 +51,10 @@ try { '_leftStackMutationObserver', '_rightStackMutationObserver', ].filter(watchObserver); - const cctvUnsubscribe = ui._cctvUnsubscribe; + const cctvUnsubscribe = ui._cctvControls._cctvUnsubscribe; counts.cctv = 0; if (cctvUnsubscribe) - ui._cctvUnsubscribe = () => { + ui._cctvControls._cctvUnsubscribe = () => { counts.cctv++; return cctvUnsubscribe(); }; @@ -82,10 +82,10 @@ try { observersReleased: observed.every( (name) => counts[name] === 1 && ui[name] === null, ), - subscriptionReleased: counts.cctv === 1 && ui._cctvUnsubscribe === null, + subscriptionReleased: counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null, resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null, controlsReleased: - ui._radioControls.destroyed && ui._locationControls.destroyed, + ui._radioControls.destroyed && ui._locationControls.destroyed && ui._cctvControls.destroyed, idempotent: once === JSON.stringify(counts), }; } finally { @@ -109,7 +109,7 @@ try { result.resizeReleased, ); check( - 'Radio and Location controls are disposed with the UI', + 'Radio, Location and CCTV controls are disposed with the UI', result.controlsReleased, ); check('repeated UI disposal is inert', result.idempotent); diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 404831aa0..5f4abd82e 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -95,3 +95,25 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async assert.deepEqual(transitions, [false]); assert.deepEqual(diagnostics, []); }); + + +test('a disposed UI cannot activate a camera when enabling finishes late', async () => { + let release; + let disposed = false; + let activations = 0; + const pending = runCctvLayerEnableTransition({ + target: true, + setEnabled: () => new Promise((resolve) => { release = resolve; }), + readOwnership: () => ({}), + shouldFocus: () => !disposed, + activate: () => { activations++; return 'camera-a'; }, + fly: () => assert.fail('disposed UI must not fly'), + debug: () => {}, + }); + disposed = true; + release(); + await pending; + assert.equal(activations, 0); + const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + assert.match(uiSource, /shouldFocus: \(\) => !this\._disposed && this\._dataManager\.isEnabled\('cctv'\)/); +}); diff --git a/src/ui.js b/src/ui.js index d8bf096bc..fa0637c2a 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,3 +1,4 @@ +import { CctvControls } from './ui/cctv.js'; import { RadioControls } from './ui/radio.js'; import { LocationControls, LocationSearch } from './ui/location.js'; import { bindClearLayersControl } from './ui/layers.js'; @@ -370,54 +371,6 @@ const STYLE_STATUS_LABELS = { * - Intel HUD lifecycle and variant switching. */ -/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ -const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; - -/** - * Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the - * camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old - * "HEADING 0°" nonsense); typed values convert back to calibration offsets - * against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon - * typing is user-hostile). - */ -const CCTV_CAL_FIELDS = { - heading: { - label: 'HDG', unit: '°', decimals: 1, - get: (cam) => cam.headingDeg, - toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), - }, - pitch: { - label: 'PITCH', unit: '°', decimals: 1, - get: (cam) => cam.pitchDeg, - toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), - }, - fov: { - label: 'FOV', unit: '°', decimals: 0, - get: (cam) => cam.fovDeg, - toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), - }, - range: { - label: 'RANGE', unit: 'm', decimals: 0, - get: (cam) => cam.rangeM, - toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), - }, - height: { - label: 'HGT', unit: 'm', decimals: 0, - get: (cam) => cam.mountHeightM, - toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), - }, - north: { - label: 'ΔN', unit: 'm', decimals: 1, - get: (cam) => cam.calibration?.offsetNorthM || 0, - toPatch: (value) => ({ offsetNorthM: value }), - }, - east: { - label: 'ΔE', unit: 'm', decimals: 1, - get: (cam) => cam.calibration?.offsetEastM || 0, - toPatch: (value) => ({ offsetEastM: value }), - }, -}; - function formatCockpitBriefAge(value) { const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN'; @@ -2084,15 +2037,7 @@ export class StyleManager { this._clearSelectedLayersPromise = null; this._clearSelectedLayersManagerPromise = null; this._dataManager = null; - this._cctvUnsubscribe = null; - this._cctvState = null; - this._cctvSummaryTypingTimer = null; - this._lastCctvSummaryText = ''; - // Auto-expand guard: last active camera id seen while the layer was - // enabled; routine state notifications with the same id never re-expand. - this._lastSeenCctvActiveId = null; - this._cctvChipHideTimer = null; - this._cctvChipWasBusy = false; + this._leftStackLayoutFrame = null; this._leftStackReconsiderAutoCollapse = false; this._leftStackResizeObserver = null; @@ -2261,8 +2206,6 @@ export class StyleManager { this._cctvCalibResetBtn = document.getElementById('cctv-calib-reset-btn'); this._cctvFrame = document.getElementById('cctv-frame'); this._cctvFrameWrap = document.getElementById('cctv-frame-wrap'); - this._cctvFrameRequestToken = 0; - this._cctvFramePreloader = null; this._cctvSourceBadge = document.getElementById('cctv-source-badge'); this._cctvMeta = document.getElementById('cctv-meta'); this._cctvSummary = document.getElementById('cctv-summary'); @@ -3674,52 +3617,6 @@ export class StyleManager { this._trafficSyncChip.classList.toggle('visible', presentation.visible); } - /** - * Updates the CCTV loading chip (same pattern as the traffic sync chip) - * with staggered initial-load progress, e.g. "LOADING FRAMES 12/36". - * Shows a brief "camera grid ready" confirmation, then hides. - * @param {{active: boolean, loaded: number, total: number}|null|undefined} loading - * Loading progress from the CCTV layer UI state. - * @param {boolean} enabled - Whether the CCTV layer is currently enabled. - * @returns {void} - */ - _updateCctvSyncChip(loading, enabled) { - if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; - const total = Number(loading?.total) || 0; - const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); - const busy = !!enabled && !!loading?.active && total > 0; - - if (busy) { - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = null; - this._cctvChipWasBusy = true; - setSplitFlapText(this._cctvSyncLabel, 'loading frames'); - // The counter is left plain on purpose: it ticks every few frames - // during a grid load, and flapping it would read as a slot machine. - this._cctvSyncProgress.textContent = `${loaded}/${total}`; - this._cctvSyncChip.classList.add('visible'); - return; - } - - if (this._cctvChipWasBusy && enabled && total > 0) { - // Load just completed — flash the final count, then auto-hide. - this._cctvChipWasBusy = false; - setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); - this._cctvSyncProgress.textContent = `${total}/${total}`; - this._cctvSyncChip.classList.add('visible'); - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = window.setTimeout(() => { - this._cctvChipHideTimer = null; - this._cctvSyncChip.classList.remove('visible'); - }, 1500); - return; - } - - if (!this._cctvChipHideTimer) { - this._cctvChipWasBusy = false; - this._cctvSyncChip.classList.remove('visible'); - } - } /** * Initializes panel collapse buttons and restores persisted collapsed state. @@ -4173,18 +4070,7 @@ export class StyleManager { }); } this._syncContextModeButtons(); - if (this._cctvUnsubscribe) { - this._cctvUnsubscribe(); - this._cctvUnsubscribe = null; - } - if (typeof cctvLayer.subscribe === 'function') { - this._cctvUnsubscribe = cctvLayer.subscribe((state) => { - this._renderCctvState(state); - }); - } - if (typeof cctvLayer.getUIState === 'function') { - this._renderCctvState(cctvLayer.getUIState()); - } + this._cctvControls.connect(); this._radioControls.connect(); if (!this._awarenessSelectedHandler) { this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false); @@ -4999,6 +4885,7 @@ export class StyleManager { /** Wire the independent Radio companion controls. */ _initRadioPanel() { + this._cctvControls?.destroy(); this._radioControls?.destroy(); this._radioControls = new RadioControls({ elements: { @@ -5091,351 +4978,50 @@ export class StyleManager { return this._runExplicitNavigation('camera', () => focus(cameraId)); } - /** - * Wires up all CCTV panel controls: enable/disable, nearest/prev/next camera, - * camera select dropdown, focus, coverage, auto-hop, projection, - * manual calibration sliders, and save/reset buttons. - * @returns {void} - */ + /** Compose camera panel controls from the existing camera port and application actions. */ _initCctvPanel() { - if (!this._cctvPanel) return; - - this._cctvEnableBtn?.addEventListener('click', async () => { - await this._toggleCctvEnabled(); - }); - - this._cctvNearestBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.focusNearest({ focus: false }), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.8), - ); - }); - - this._cctvPrevBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.cycleCamera(-1), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), - ); - }); - - this._cctvNextBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.cycleCamera(1), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), - ); - }); - - this._cctvSelect?.addEventListener('change', async () => { - const cameraId = this._cctvSelect.value; - if (!cameraId) return; - if (!await this._toggleCctvEnabled(true)) return; - // Picking a camera from the dropdown flies to it. The catalog spans - // three metros, so a bare selection used to leave the view in the old - // city with a camera active thousands of km away. - this._runExplicitCctvFocus( - () => (cctvLayer.selectCamera(cameraId) ? cameraId : null), - (selectedId) => cctvLayer.focusCamera(selectedId, 2.2), - ); - this._dataManager?.setLayerParams('cctv', { selectedCameraId: cameraId }, { origin: 'user' }); - }); - - this._cctvFocusBtn?.addEventListener('click', async () => { - const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; - if (!selected) return; - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => selected, - (cameraId) => cctvLayer.focusCamera(cameraId, 1.9), - ); - this._dataManager?.setLayerParams('cctv', { selectedCameraId: selected }, { origin: 'user' }); - }); - - this._cctvCoverageBtn?.addEventListener('click', () => { - const current = this._cctvState?.coverageMode - || (this._cctvState?.showCoverage ? 'on' : 'off'); - const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; - this._dataManager?.setLayerParams('cctv', { coverageMode: next }, { origin: 'user' }); - }); - - this._cctvAutoHopBtn?.addEventListener('click', () => { - const current = !!this._cctvState?.autoHop; - this._dataManager?.setLayerParams('cctv', { autoHop: !current }, { origin: 'user' }); - }); - - this._cctvProjectionBtn?.addEventListener('click', () => { - const current = this._cctvState?.showProjection !== false; - this._dataManager?.setLayerParams('cctv', { showProjection: !current }, { origin: 'user' }); - }); - - this._cctvAdjustBtn?.addEventListener('click', () => { - const current = !!this._cctvState?.calibrationMode; - this._dataManager?.setLayerParams('cctv', { calibrationMode: !current }, { origin: 'user' }); - }); - - // Click-to-edit pose readout: each chip swaps to a number input; Enter or - // blur commits (converted to a calibration offset against basePose), - // Escape cancels. Delegated so re-renders never re-bind. - this._cctvCalReadout?.addEventListener('click', (event) => { - const chip = event.target.closest?.('.cctv-cal-value'); - if (!chip || chip.disabled || chip.querySelector('input')) return; - this._beginCctvCalValueEdit(chip); - }); - - this._cctvCalibSaveBtn?.addEventListener('click', () => { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this._dataManager) return; - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { cameraId, save: true }, - }, { origin: 'user' }); - this._showToast('CCTV calibration saved'); - }); - - this._cctvCalibResetBtn?.addEventListener('click', () => { - this._resetCctvCalibration(); - }); - - this._renderCctvState(null); - this._syncCctvPanelViewport(); - } - - /** - * Returns the currently active CCTV camera ID from state or the select dropdown. - * @returns {string} Camera ID, or empty string if none. - */ - _activeCctvCameraId() { - return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; - } - - /** - * Clears the preview and invalidates any in-flight preload. - * @returns {void} - */ - _clearCctvFrame() { - this._cctvFrameRequestToken += 1; - this._cctvFramePreloader = null; - if (this._cctvFrame) { - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrame.dataset.cameraId = ''; - this._cctvFrame.dataset.currentSrc = ''; - this._cctvFrame.dataset.loading = ''; - this._cctvFrame.dataset.error = ''; - } - this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); - } - - /** - * Fetches a replacement frame OFF-DOM and assigns it to the live element - * only once it has decoded. - * - * The live is never pointed at an unresolved URL. A completed - * preload is already in the HTTP cache, so assigning `src` swaps in a - * single paint — the browser's own atomic behavior. A slow or failed - * fetch never reaches the element at all, so settled pixels survive. - * - * (A two-slot crossfade was tried and reverted: with no z-index the slots - * paint in DOM order, so promotion was asymmetric and yanked the visible - * layer once per refresh — a flicker on every feed cycle. Measured against - * main, which never blanked on a successful refresh in the first place.) - * - * @param {string} src - * @param {string} cameraId - * @param {boolean} cameraChanged - * @returns {void} - */ - _queueCctvFrame(src, cameraId, cameraChanged) { - if (!this._cctvFrame || !src) return; - - if (cameraChanged) { - // A different camera gets an honest acquisition state. Never retain - // the prior camera's pixels under the newly selected metadata. - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrameWrap?.classList.remove('has-frame'); - } - - const token = ++this._cctvFrameRequestToken; - this._cctvFrame.dataset.cameraId = cameraId; - this._cctvFrame.dataset.currentSrc = src; - this._cctvFrame.dataset.loading = 'true'; - this._cctvFrame.dataset.error = ''; - this._cctvFrameWrap?.classList.toggle( - 'loading', - !this._cctvFrameWrap?.classList.contains('has-frame') - ); - - const preloader = new Image(); - this._cctvFramePreloader = preloader; - preloader.onload = () => this._settleCctvFrame(token, src, true); - preloader.onerror = () => this._settleCctvFrame(token, src, false); - preloader.src = src; - } - - /** - * Commits a decoded frame to the live element, or records the failure - * without disturbing whatever is already on screen. - * @param {number} token - Request token; a stale one is ignored. - * @param {string} src - * @param {boolean} ok - * @returns {void} - */ - _settleCctvFrame(token, src, ok) { - if (!this._cctvFrame || token !== this._cctvFrameRequestToken) return; - this._cctvFramePreloader = null; - this._cctvFrame.dataset.loading = ''; - this._cctvFrameWrap?.classList.remove('loading'); - - const syncBadge = () => this._syncCctvSourceBadge( - this._cctvState?.activeCamera, - !!this._cctvState?.enabled && !!this._dataManager?.isEnabled('cctv') - ); - - if (!ok) { - // Leave the element untouched — a settled frame stays on screen. - this._cctvFrame.dataset.error = 'true'; - syncBadge(); - return; - } - - this._cctvFrame.dataset.error = ''; - this._cctvFrame.src = src; - this._cctvFrame.classList.add('active'); - this._cctvFrameWrap?.classList.add('has-frame'); - syncBadge(); - } - - /** - * Keeps the source badge truthful about the visible frame lifecycle. Health - * may already be OK while the browser is still decoding the requested image. - * @param {object|null} activeCamera - * @param {boolean} enabled - * @returns {void} - */ - _syncCctvSourceBadge(activeCamera, enabled) { - if (!this._cctvSourceBadge) return; - if (!enabled || !activeCamera) { - this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; - this._cctvSourceBadge.dataset.frameState = 'idle'; - return; - } - const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); - if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · LOADING'; - this._cctvSourceBadge.dataset.frameState = 'loading'; - return; - } - if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; - this._cctvSourceBadge.dataset.frameState = 'error'; - return; - } - const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); - const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); - this._cctvSourceBadge.textContent = `${kind} · ${status}`; - this._cctvSourceBadge.dataset.frameState = 'ready'; - } - - /** - * Resets calibration for the active CCTV camera to its server defaults. - * @returns {void} - */ - _resetCctvCalibration() { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this._dataManager) return; - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { - cameraId, - reset: true, + this._cctvControls?.destroy(); + this._cctvControls = new CctvControls({ + elements: { + _cctvAdjustBtn: this._cctvAdjustBtn, + _cctvAutoHopBtn: this._cctvAutoHopBtn, + _cctvCalReadout: this._cctvCalReadout, + _cctvCalibResetBtn: this._cctvCalibResetBtn, + _cctvCalibSaveBtn: this._cctvCalibSaveBtn, + _cctvCoverageBtn: this._cctvCoverageBtn, + _cctvEnableBtn: this._cctvEnableBtn, + _cctvFocusBtn: this._cctvFocusBtn, + _cctvFrame: this._cctvFrame, + _cctvFrameWrap: this._cctvFrameWrap, + _cctvMeta: this._cctvMeta, + _cctvNearestBtn: this._cctvNearestBtn, + _cctvNextBtn: this._cctvNextBtn, + _cctvPanel: this._cctvPanel, + _cctvPrevBtn: this._cctvPrevBtn, + _cctvProjectionBtn: this._cctvProjectionBtn, + _cctvQualityChip: this._cctvQualityChip, + _cctvSelect: this._cctvSelect, + _cctvSourceBadge: this._cctvSourceBadge, + _cctvSummary: this._cctvSummary, + _cctvSyncChip: this._cctvSyncChip, + _cctvSyncLabel: this._cctvSyncLabel, + _cctvSyncProgress: this._cctvSyncProgress, + }, + cctv: cctvLayer, + actions: { + isEnabled: () => this._dataManager?.isEnabled('cctv'), + setParams: (params, options) => this._dataManager?.setLayerParams('cctv', params, options), + toggleEnabled: (...args) => this._toggleCctvEnabled(...args), + runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), + setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + showToast: (message) => this._showToast(message), + syncViewport: () => this._syncCctvPanelViewport(), + setSplitFlapText, }, - }, { origin: 'user' }); - this._showToast('CCTV calibration reset'); - } - - /** - * Swaps a readout chip's text for an inline number input. Enter/blur commits - * (converted to a calibration offset patch), Escape cancels. The next state - * re-render restores the chip text either way. - * @param {HTMLButtonElement} chip - The clicked `.cctv-cal-value` element. - * @returns {void} - */ - _beginCctvCalValueEdit(chip) { - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - const activeCamera = this._cctvState?.activeCamera; - if (!field || !activeCamera?.basePose) return; - const startValue = field.get(activeCamera); - const input = document.createElement('input'); - input.type = 'number'; - input.step = field.decimals > 0 ? '0.1' : '1'; - input.value = Number(startValue).toFixed(field.decimals); - input.className = 'cctv-cal-input'; - chip.textContent = `${field.label} `; - chip.appendChild(input); - input.focus(); - input.select(); - - let finished = false; - const finish = (commit) => { - if (finished) return; - finished = true; - const typed = parseFloat(input.value); - input.remove(); - if (commit && Number.isFinite(typed)) { - const cameraId = this._activeCctvCameraId(); - if (cameraId && this._dataManager) { - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { cameraId, patch: field.toPatch(typed, activeCamera.basePose) }, - }, { origin: 'user' }); - return; // re-render restores the chip text from fresh state - } - } - this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); - }; - input.addEventListener('keydown', (event) => { - if (event.key === 'Enter') finish(true); - else if (event.key === 'Escape') finish(false); - event.stopPropagation(); }); - input.addEventListener('blur', () => finish(true)); - input.addEventListener('click', (event) => event.stopPropagation()); } - /** - * Synchronizes the pose readout chips, ADJUST button, and SAVE/RESET - * disabled states with the active camera. - * @param {boolean} enabled - Whether the CCTV layer is currently enabled. - * @param {object|null} activeCamera - The active camera data object (may be null). - * @returns {void} - */ - _syncCctvCalReadout(enabled, activeCamera) { - const canCalibrate = !!enabled && !!activeCamera; - if (this._cctvAdjustBtn) { - const adjustOn = !!this._cctvState?.calibrationMode; - this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); - this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; - this._cctvAdjustBtn.disabled = !canCalibrate; - } - if (this._cctvCalReadout) { - for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { - if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - if (!field) continue; - const value = canCalibrate ? field.get(activeCamera) : null; - chip.textContent = Number.isFinite(value) - ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` - : `${field.label} --`; - chip.disabled = !canCalibrate; - } - } - for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { - if (el) el.disabled = !canCalibrate; - } - } + /** * Toggles the CCTV layer enabled state. When enabling and no camera is active, @@ -5444,6 +5030,7 @@ export class StyleManager { * @returns {Promise} True if the layer is now in the requested state. */ async _toggleCctvEnabled(forceState) { + if (this._disposed) return false; if (!this._dataManager || !this._dataManager.layers?.has('cctv')) { this._showToast('CCTV layer unavailable'); return false; @@ -5458,7 +5045,8 @@ export class StyleManager { trackedEntity: this.viewer?.trackedEntity, cockpitActive: !!this.cockpitView?.active, }), - shouldFocus: () => !this._cctvState?.activeCameraId, + shouldFocus: () => !this._disposed && this._dataManager.isEnabled('cctv') + && !this._cctvControls?.getState()?.activeCameraId, activate: () => cctvLayer.focusNearest({ focus: false }), fly: (cameraId) => this._runExplicitCctvFocus( () => cameraId, @@ -5468,195 +5056,6 @@ export class StyleManager { return true; } - /** - * Maps a `deriveCalBadge` value (cctv.js) to its panel copy. Single source - * of truth for CAL-badge casing — used by both the quality chip and the - * meta line so the two never drift onto different label conventions. - * @param {'calibrated'|'curated'|'raw-prior'|null} badge - Badge state from cctv.js. - * @returns {string} Display label, or '--' when there is no active camera. - */ - _calBadgeLabel(badge) { - switch (badge) { - case 'calibrated': return 'CALIBRATED'; - case 'curated': return 'CURATED'; - case 'raw-prior': return 'RAW PRIOR'; - default: return '--'; - } - } - - /** - * Full re-render of the CCTV panel UI from a CCTV layer state snapshot. - * Updates enable button, camera select dropdown, navigation buttons, - * coverage/auto-hop/projection toggles, quality chip, source badge, - * metadata line, frame image, calibration controls, and summary text. - * @param {object|null} state - CCTV layer UI state, or null to render empty. - * @returns {void} - */ - _renderCctvState(state) { - this._cctvState = state || null; - const cameras = state?.cameras || []; - const enabled = !!state?.enabled && !!this._dataManager?.isEnabled('cctv'); - const activeId = state?.activeCameraId || ''; - const activeCamera = state?.activeCamera || null; - - // Auto-expand the panel when the active camera CHANGES to a new non-null - // id while the layer is enabled. Covers click-on-globe, panel controls, - // and voice (selectCamera/cycleCamera/focusNearest all notify through - // this subscription). The last-seen guard keeps routine notifications - // from re-expanding a panel the user deliberately collapsed, and timed - // auto-hop transitions only expand on the first activation so the panel - // does not pop open on every hop. - const effectiveActiveId = enabled ? (activeId || null) : null; - const isFirstActivation = this._lastSeenCctvActiveId === null; - if (effectiveActiveId - && effectiveActiveId !== this._lastSeenCctvActiveId - && (!state?.autoHop || isFirstActivation)) { - this.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); - } - this._lastSeenCctvActiveId = effectiveActiveId; - - this._updateCctvSyncChip(state?.loading, enabled); - - if (this._cctvEnableBtn) { - this._cctvEnableBtn.classList.toggle('active', enabled); - this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; - } - - if (this._cctvSelect) { - const shouldRebuild = this._cctvSelect.options.length !== cameras.length - || cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); - if (shouldRebuild) { - this._cctvSelect.innerHTML = ''; - for (const camera of cameras) { - const option = document.createElement('option'); - option.value = camera.id; - option.textContent = `${camera.city} · ${camera.name}`; - this._cctvSelect.appendChild(option); - } - } - this._cctvSelect.disabled = !enabled || cameras.length === 0; - if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { - this._cctvSelect.value = activeId; - } else if (!activeId) { - this._cctvSelect.selectedIndex = -1; - } - } - - for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { - if (!btn) continue; - btn.disabled = !enabled || cameras.length === 0; - } - if (this._cctvFocusBtn) { - this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; - } - - if (this._cctvCoverageBtn) { - // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed - // (color-coded volumes). The click handler cycles; this renders. - const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); - this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); - this._cctvCoverageBtn.textContent = mode === 'viewshed' - ? 'VIEWSHED ON' - : mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; - this._cctvCoverageBtn.disabled = !enabled; - } - - if (this._cctvAutoHopBtn) { - const autoHop = !!state?.autoHop; - this._cctvAutoHopBtn.classList.toggle('active', autoHop); - this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; - this._cctvAutoHopBtn.disabled = !enabled; - } - - if (this._cctvProjectionBtn) { - const showProjection = state?.showProjection !== false; - this._cctvProjectionBtn.classList.toggle('active', showProjection); - this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; - this._cctvProjectionBtn.disabled = !enabled; - } - - if (this._cctvQualityChip) { - // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, - // no in-world tint): three states driven by cctv.js's deriveCalBadge, - // no client-side scoring math. Casing is unified via _calBadgeLabel so - // the chip and the meta line never drift onto different conventions. - // Save-gated persistence (viewshed design §3e): unsaved live edits show - // EDITED on top of whatever the persisted badge state is — SAVE CAL - // promotes to CALIBRATED, RESET CAL clears. - const badge = activeCamera?.calBadge || null; - const dirty = !!activeCamera?.calDirty; - this._cctvQualityChip.textContent = dirty - ? 'CAL · EDITED (UNSAVED)' - : `CAL · ${this._calBadgeLabel(badge)}`; - this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); - } - - this._syncCctvCalReadout(enabled, activeCamera); - - if (this._cctvMeta) { - if (activeCamera) { - const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; - const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; - const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; - const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; - this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; - } else if (cameras.length > 0) { - this._cctvMeta.textContent = enabled - ? `${cameras.length} cameras loaded · click a camera to activate` - : `${cameras.length} cameras loaded · enable CCTV to activate`; - } else { - this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; - } - } - - if (this._cctvFrame) { - const nextSrc = enabled ? activeCamera?.frameUrl : null; - const nextCameraId = enabled ? (activeCamera?.id || '') : ''; - const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; - const frameLoading = this._cctvFrame.dataset.loading === 'true'; - // A same-camera refresh waits for the current image to settle. Replacing - // src every 10 seconds can cancel a slow but healthy decode forever and - // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are - // immediate so navigation never waits on the prior camera's request. - if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { - this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); - } - if (!nextSrc) { - this._clearCctvFrame(); - } - } - - this._syncCctvSourceBadge(activeCamera, enabled); - this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); - } - - /** - * Typewriter-animates CCTV summary text into the summary element. - * Skips animation if the text hasn't changed since the last call. - * Advances 3 characters per 20ms tick for a fast teletype effect. - * @param {string} text - Summary text to display. - * @returns {void} - */ - _typeCctvSummary(text) { - if (!this._cctvSummary) return; - const nextText = String(text || '').trim() || 'No summary available.'; - if (nextText === this._lastCctvSummaryText) return; - this._lastCctvSummaryText = nextText; - - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummary.textContent = ''; - let idx = 0; - this._cctvSummaryTypingTimer = setInterval(() => { - idx += 3; - if (idx >= nextText.length) { - this._cctvSummary.textContent = nextText; - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummaryTypingTimer = null; - return; - } - this._cctvSummary.textContent = nextText.slice(0, idx); - }, 20); - } /** * Returns the versioned localStorage key for a panel's saved position. @@ -8508,6 +7907,7 @@ export class StyleManager { this._mapSourceControls?.destroy(); this._clearLayersControl?.destroy(); this._locationControls?.destroy(); + this._cctvControls?.destroy(); this._radioControls?.destroy(); this._visualEffects.stop(); this._styleParameters?.destroy(); @@ -8594,8 +7994,6 @@ export class StyleManager { this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; - this._cctvUnsubscribe?.(); - this._cctvUnsubscribe = null; this._commandDockTrayObserver?.disconnect?.(); this._commandDockTrayObserver = null; this._draggableResizeObserver?.disconnect(); diff --git a/src/ui/cctv.js b/src/ui/cctv.js new file mode 100644 index 000000000..9cfcb9760 --- /dev/null +++ b/src/ui/cctv.js @@ -0,0 +1 @@ +export { CctvControls } from './cctvControls.js'; diff --git a/src/ui/cctvBindings.js b/src/ui/cctvBindings.js new file mode 100644 index 000000000..5c70c2c65 --- /dev/null +++ b/src/ui/cctvBindings.js @@ -0,0 +1,130 @@ +export function _initCctvPanel() { + if (!this._cctvPanel) return; + + this.listen(this._cctvEnableBtn, 'click', async () => { + this._actionGeneration++; + await this.actions.toggleEnabled(); + }); + + this.listen(this._cctvNearestBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + this.actions.runExplicitFocus( + () => this.cctv.focusNearest({ focus: false }), + (cameraId) => this.cctv.focusCamera(cameraId, 1.8), + ); + }); + + this.listen(this._cctvPrevBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + this.actions.runExplicitFocus( + () => this.cctv.cycleCamera(-1), + (cameraId) => this.cctv.focusCamera(cameraId, 1.4), + ); + }); + + this.listen(this._cctvNextBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + this.actions.runExplicitFocus( + () => this.cctv.cycleCamera(1), + (cameraId) => this.cctv.focusCamera(cameraId, 1.4), + ); + }); + + this.listen(this._cctvSelect, 'change', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + const cameraId = this._cctvSelect.value; + if (!cameraId) return; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + // Picking a camera from the dropdown flies to it. The catalog spans + // three metros, so a bare selection used to leave the view in the old + // city with a camera active thousands of km away. + this.actions.runExplicitFocus( + () => (this.cctv.selectCamera(cameraId) ? cameraId : null), + (selectedId) => this.cctv.focusCamera(selectedId, 2.2), + ); + this.actions.setParams({ selectedCameraId: cameraId }, { origin: 'user' }); + }); + + this.listen(this._cctvFocusBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; + if (!selected) return; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + this.actions.runExplicitFocus( + () => selected, + (cameraId) => this.cctv.focusCamera(cameraId, 1.9), + ); + this.actions.setParams({ selectedCameraId: selected }, { origin: 'user' }); + }); + + this.listen(this._cctvCoverageBtn, 'click', () => { + const current = this._cctvState?.coverageMode + || (this._cctvState?.showCoverage ? 'on' : 'off'); + const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; + this.actions.setParams({ coverageMode: next }, { origin: 'user' }); + }); + + this.listen(this._cctvAutoHopBtn, 'click', () => { + const current = !!this._cctvState?.autoHop; + this.actions.setParams({ autoHop: !current }, { origin: 'user' }); + }); + + this.listen(this._cctvProjectionBtn, 'click', () => { + const current = this._cctvState?.showProjection !== false; + this.actions.setParams({ showProjection: !current }, { origin: 'user' }); + }); + + this.listen(this._cctvAdjustBtn, 'click', () => { + const current = !!this._cctvState?.calibrationMode; + this.actions.setParams({ calibrationMode: !current }, { origin: 'user' }); + }); + + // Click-to-edit pose readout: each chip swaps to a number input; Enter or + // blur commits (converted to a calibration offset against basePose), + // Escape cancels. Delegated so re-renders never re-bind. + this.listen(this._cctvCalReadout, 'click', (event) => { + const chip = event.target.closest?.('.cctv-cal-value'); + if (!chip || chip.disabled || chip.querySelector('input')) return; + this._beginCctvCalValueEdit(chip); + }); + + this.listen(this._cctvCalibSaveBtn, 'click', () => { + const cameraId = this._activeCctvCameraId(); + if (!cameraId || !this.actions.setParams) return; + this.actions.setParams({ + selectedCameraId: cameraId, + calibration: { cameraId, save: true }, + }, { origin: 'user' }); + this.actions.showToast('CCTV calibration saved'); + }); + + this.listen(this._cctvCalibResetBtn, 'click', () => { + this._resetCctvCalibration(); + }); + + this._renderCctvState(null); + this.actions.syncViewport(); +} diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js new file mode 100644 index 000000000..6d2d4487b --- /dev/null +++ b/src/ui/cctvCalibration.js @@ -0,0 +1,140 @@ +/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ +const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; + +/** + * Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the + * camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old + * "HEADING 0°" nonsense); typed values convert back to calibration offsets + * against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon + * typing is user-hostile). + */ +const CCTV_CAL_FIELDS = { + heading: { + label: 'HDG', unit: '°', decimals: 1, + get: (cam) => cam.headingDeg, + toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), + }, + pitch: { + label: 'PITCH', unit: '°', decimals: 1, + get: (cam) => cam.pitchDeg, + toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), + }, + fov: { + label: 'FOV', unit: '°', decimals: 0, + get: (cam) => cam.fovDeg, + toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), + }, + range: { + label: 'RANGE', unit: 'm', decimals: 0, + get: (cam) => cam.rangeM, + toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), + }, + height: { + label: 'HGT', unit: 'm', decimals: 0, + get: (cam) => cam.mountHeightM, + toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), + }, + north: { + label: 'ΔN', unit: 'm', decimals: 1, + get: (cam) => cam.calibration?.offsetNorthM || 0, + toPatch: (value) => ({ offsetNorthM: value }), + }, + east: { + label: 'ΔE', unit: 'm', decimals: 1, + get: (cam) => cam.calibration?.offsetEastM || 0, + toPatch: (value) => ({ offsetEastM: value }), + }, +}; + +export function _activeCctvCameraId() { + return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; +} + +export function _resetCctvCalibration() { + const cameraId = this._activeCctvCameraId(); + if (!cameraId || !this.actions.setParams) return; + this.actions.setParams({ + selectedCameraId: cameraId, + calibration: { + cameraId, + reset: true, + }, + }, { origin: 'user' }); + this.actions.showToast('CCTV calibration reset'); +} + +export function _beginCctvCalValueEdit(chip) { + if (this.destroyed) return; + this._calibrationEdit?.(false); + const field = CCTV_CAL_FIELDS[chip.dataset.calField]; + const activeCamera = this._cctvState?.activeCamera; + if (!field || !activeCamera?.basePose) return; + const cameraId = this._activeCctvCameraId(); + const basePose = { ...activeCamera.basePose }; + const editListeners = new AbortController(); + const startValue = field.get(activeCamera); + const input = document.createElement('input'); + input.type = 'number'; + input.step = field.decimals > 0 ? '0.1' : '1'; + input.value = Number(startValue).toFixed(field.decimals); + input.className = 'cctv-cal-input'; + chip.textContent = `${field.label} `; + chip.appendChild(input); + input.focus(); + input.select(); + + let finished = false; + const finish = (commit) => { + if (finished) return; + finished = true; + editListeners.abort(); + this._calibrationEdit = null; + const typed = parseFloat(input.value); + input.remove(); + if (commit && !this.destroyed && Number.isFinite(typed) + && this._cctvState?.enabled && this.actions.isEnabled() + && cameraId === this._activeCctvCameraId()) { + if (cameraId && this.actions.setParams) { + this.actions.setParams({ + selectedCameraId: cameraId, + calibration: { cameraId, patch: field.toPatch(typed, basePose) }, + }, { origin: 'user' }); + return; // re-render restores the chip text from fresh state + } + } + this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); + }; + this._calibrationEdit = finish; + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') finish(true); + else if (event.key === 'Escape') { event.preventDefault(); finish(false); } + event.stopPropagation(); + }, { signal: editListeners.signal }); + input.addEventListener('blur', () => finish(true), { signal: editListeners.signal }); + input.addEventListener('click', (event) => event.stopPropagation(), { signal: editListeners.signal }); +} + +export function _syncCctvCalReadout(enabled, activeCamera) { + const canCalibrate = !!enabled && !!activeCamera; + if (this._cctvAdjustBtn) { + const adjustOn = !!this._cctvState?.calibrationMode; + this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); + this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; + this._cctvAdjustBtn.disabled = !canCalibrate; + } + if (this._cctvCalReadout) { + for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { + if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber + const field = CCTV_CAL_FIELDS[chip.dataset.calField]; + if (!field) continue; + const value = canCalibrate ? field.get(activeCamera) : null; + chip.textContent = Number.isFinite(value) + ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` + : `${field.label} --`; + chip.disabled = !canCalibrate; + } + } + for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { + if (el) el.disabled = !canCalibrate; + } +} diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js new file mode 100644 index 000000000..46805ad69 --- /dev/null +++ b/src/ui/cctvControls.js @@ -0,0 +1,66 @@ +import { _clearCctvFrame, _queueCctvFrame, _settleCctvFrame, _syncCctvSourceBadge } from './cctvFrames.js'; +import { _activeCctvCameraId, _resetCctvCalibration, _beginCctvCalValueEdit, _syncCctvCalReadout } from './cctvCalibration.js'; +import { _calBadgeLabel, _renderCctvState, _typeCctvSummary, _updateCctvSyncChip } from './cctvPresentation.js'; +import { _initCctvPanel } from './cctvBindings.js'; + +/** Own camera-panel interaction and presentation; receive the camera port and application actions. */ +export class CctvControls { + constructor({ elements, cctv, actions }) { + Object.assign(this, elements); + this.cctv = cctv; + this.actions = actions; + this.destroyed = false; + this.listeners = new AbortController(); + this._cctvUnsubscribe = null; + this._cctvState = null; + this._cctvSummaryTypingTimer = null; + this._lastCctvSummaryText = ''; + this._lastSeenCctvActiveId = null; + this._cctvChipHideTimer = null; + this._cctvChipWasBusy = false; + this._cctvFrameRequestToken = 0; + this._cctvFramePreloader = null; + this._calibrationEdit = null; + this._actionGeneration = 0; + this._initCctvPanel(); + } + listen(target, type, handler, options = {}) { + target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal }); + } + getState() { return this._cctvState; } + connect() { + this._cctvUnsubscribe?.(); + this._cctvUnsubscribe = null; + if (this.destroyed) return; + this._cctvUnsubscribe = this.cctv.subscribe?.((state) => this._renderCctvState(state)); + if (this.cctv.getUIState) this._renderCctvState(this.cctv.getUIState()); + } + _clearCctvFrame(...args) { return _clearCctvFrame.call(this, ...args); } + _queueCctvFrame(...args) { return _queueCctvFrame.call(this, ...args); } + _settleCctvFrame(...args) { return _settleCctvFrame.call(this, ...args); } + _syncCctvSourceBadge(...args) { return _syncCctvSourceBadge.call(this, ...args); } + _activeCctvCameraId(...args) { return _activeCctvCameraId.call(this, ...args); } + _resetCctvCalibration(...args) { return _resetCctvCalibration.call(this, ...args); } + _beginCctvCalValueEdit(...args) { return _beginCctvCalValueEdit.call(this, ...args); } + _syncCctvCalReadout(...args) { return _syncCctvCalReadout.call(this, ...args); } + _calBadgeLabel(...args) { return _calBadgeLabel.call(this, ...args); } + _renderCctvState(...args) { return _renderCctvState.call(this, ...args); } + _typeCctvSummary(...args) { return _typeCctvSummary.call(this, ...args); } + _updateCctvSyncChip(...args) { return _updateCctvSyncChip.call(this, ...args); } + _initCctvPanel(...args) { return _initCctvPanel.call(this, ...args); } + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this._actionGeneration++; + this.listeners.abort(); + this._cctvUnsubscribe?.(); + this._cctvUnsubscribe = null; + this._calibrationEdit?.(false); + this._clearCctvFrame(); + clearInterval(this._cctvSummaryTypingTimer); + clearTimeout(this._cctvChipHideTimer); + this._cctvSummaryTypingTimer = null; + this._cctvChipHideTimer = null; + this._cctvSyncChip?.classList.remove('visible'); + } +} diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs new file mode 100644 index 000000000..8d93a7ae1 --- /dev/null +++ b/src/ui/cctvControls.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { CctvControls } from './cctvControls.js'; + +function element() { + const classes = new Set(); + return { + dataset: {}, src: '', + removeAttribute(name) { if (name === 'src') this.src = ''; }, + classList: { + add(...values) { values.forEach(value => classes.add(value)); }, + remove(...values) { values.forEach(value => classes.delete(value)); }, + contains(value) { return classes.has(value); }, + toggle(value, enabled) { if (enabled) classes.add(value); else classes.delete(value); }, + }, + }; +} +function fixture(t) { + const prior = globalThis.Image; + const requests = []; + globalThis.Image = class { constructor() { requests.push(this); } }; + t.after(() => { globalThis.Image = prior; }); + const controls = new CctvControls({ + elements: { _cctvFrame: element(), _cctvFrameWrap: element() }, + cctv: {}, actions: { isEnabled: () => true }, + }); + t.after(() => controls.destroy()); + return { controls, requests }; +} + +test('a late image completion cannot replace a newer camera preview', (t) => { + const { controls, requests } = fixture(t); + controls._queueCctvFrame('first.jpg', 'a', true); + const stale = requests[0].onload; + controls._queueCctvFrame('second.jpg', 'b', true); + assert.equal(requests[0].onload, null); + requests[1].onload(); + stale(); + assert.equal(controls._cctvFrame.src, 'second.jpg'); + assert.equal(controls._cctvFrame.dataset.cameraId, 'b'); +}); + +test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => { + const { controls, requests } = fixture(t); + controls._queueCctvFrame('first.jpg', 'a', true); + requests[0].onload(); + controls._queueCctvFrame('refresh.jpg', 'a', false); + requests[1].onerror(); + assert.equal(controls._cctvFrame.src, 'first.jpg'); + assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), true); + controls._queueCctvFrame('other.jpg', 'b', true); + assert.equal(controls._cctvFrame.src, ''); + assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), false); +}); + +test('destroy invalidates image callbacks and releases each subscription once', (t) => { + const { controls, requests } = fixture(t); + let unsubscribed = 0; + controls.cctv.subscribe = () => () => { unsubscribed++; }; + controls.connect(); + controls.connect(); + assert.equal(unsubscribed, 1); + controls._queueCctvFrame('first.jpg', 'a', true); + const late = requests[0].onload; + controls.destroy(); + controls.destroy(); + controls.connect(); + late(); + assert.equal(unsubscribed, 2); + assert.equal(requests[0].onload, null); + assert.equal(requests[0].onerror, null); + assert.equal(controls._cctvFrame.src, ''); + controls._queueCctvFrame('late.jpg', 'b', true); + assert.equal(requests.length, 1); +}); + +function calibrationFixture(t) { + const { controls } = fixture(t); + const prior = globalThis.document; + const inputs = []; + globalThis.document = { createElement() { + const input = new EventTarget(); + Object.assign(input, { focus() {}, select() {}, remove() { this.parent.input = null; } }); + inputs.push(input); + return input; + } }; + t.after(() => { globalThis.document = prior; }); + const chip = { + dataset: { calField: 'heading' }, textContent: '', input: null, + appendChild(input) { this.input = input; input.parent = this; }, + querySelector() { return this.input; }, + }; + const patches = []; + controls.actions.setParams = (params) => patches.push(params); + controls.actions.setPanelCollapsed = () => {}; + controls._cctvCalReadout = { querySelectorAll: () => [chip] }; + controls._cctvState = { + enabled: true, activeCameraId: 'a', + activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } }, + }; + const key = (name) => Object.assign(new Event('keydown', { cancelable: true }), { key: name }); + return { controls, chip, inputs, patches, key }; +} + +test('calibration commits against the captured camera base and releases its editor', (t) => { + const { controls, chip, inputs, patches, key } = calibrationFixture(t); + controls._beginCctvCalValueEdit(chip); + inputs[0].value = '100'; + controls._cctvState.activeCamera.basePose.headingDeg = 60; + inputs[0].dispatchEvent(key('Enter')); + inputs[0].dispatchEvent(new Event('blur')); + assert.deepEqual(patches, [{ selectedCameraId: 'a', calibration: { cameraId: 'a', patch: { headingDeg: 80 } } }]); + assert.equal(controls._calibrationEdit, null); +}); + +test('a camera switch cancels calibration before old blur can change the new camera', (t) => { + const { controls, chip, inputs, patches } = calibrationFixture(t); + controls._beginCctvCalValueEdit(chip); + inputs[0].value = '100'; + controls._renderCctvState({ enabled: true, activeCameraId: 'b', activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } } }); + inputs[0].dispatchEvent(new Event('blur')); + assert.deepEqual(patches, []); + assert.equal(chip.input, null); + assert.equal(chip.textContent, 'HDG 200.0°'); +}); + +test('Escape claims calibration cancellation and disposal prevents late commits', (t) => { + const { controls, chip, inputs, patches, key } = calibrationFixture(t); + controls._beginCctvCalValueEdit(chip); + inputs[0].value = '100'; + const escape = key('Escape'); + inputs[0].dispatchEvent(escape); + inputs[0].dispatchEvent(new Event('blur')); + assert.equal(escape.defaultPrevented, true); + controls._beginCctvCalValueEdit(chip); + inputs[1].value = '120'; + controls.destroy(); + inputs[1].dispatchEvent(new Event('blur')); + assert.deepEqual(patches, []); +}); + +test('disposing during camera enable prevents the delayed focus and future clicks', async () => { + const button = new EventTarget(); + let resolveEnable; + let enables = 0; + let focuses = 0; + const controls = new CctvControls({ + elements: { _cctvPanel: {}, _cctvNextBtn: button }, cctv: {}, + actions: { + isEnabled: () => true, syncViewport() {}, + toggleEnabled() { enables++; return new Promise(resolve => { resolveEnable = resolve; }); }, + runExplicitFocus() { focuses++; }, + }, + }); + button.dispatchEvent(new Event('click')); + controls.destroy(); + resolveEnable(true); + await new Promise(resolve => setImmediate(resolve)); + button.dispatchEvent(new Event('click')); + assert.equal(enables, 1); + assert.equal(focuses, 0); +}); diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js new file mode 100644 index 000000000..fcf4ff379 --- /dev/null +++ b/src/ui/cctvFrames.js @@ -0,0 +1,102 @@ +export function _clearCctvFrame() { + this._cctvFrameRequestToken += 1; + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + this._cctvFramePreloader = null; + if (this._cctvFrame) { + this._cctvFrame.classList.remove('active'); + this._cctvFrame.removeAttribute('src'); + this._cctvFrame.dataset.cameraId = ''; + this._cctvFrame.dataset.currentSrc = ''; + this._cctvFrame.dataset.loading = ''; + this._cctvFrame.dataset.error = ''; + } + this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); +} + +export function _queueCctvFrame(src, cameraId, cameraChanged) { + if (this.destroyed || !this._cctvFrame || !src) return; + + if (cameraChanged) { + // A different camera gets an honest acquisition state. Never retain + // the prior camera's pixels under the newly selected metadata. + this._cctvFrame.classList.remove('active'); + this._cctvFrame.removeAttribute('src'); + this._cctvFrameWrap?.classList.remove('has-frame'); + } + + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + const token = ++this._cctvFrameRequestToken; + this._cctvFrame.dataset.cameraId = cameraId; + this._cctvFrame.dataset.currentSrc = src; + this._cctvFrame.dataset.loading = 'true'; + this._cctvFrame.dataset.error = ''; + this._cctvFrameWrap?.classList.toggle( + 'loading', + !this._cctvFrameWrap?.classList.contains('has-frame') + ); + + const preloader = new Image(); + this._cctvFramePreloader = preloader; + preloader.onload = () => this._settleCctvFrame(token, src, true); + preloader.onerror = () => this._settleCctvFrame(token, src, false); + preloader.src = src; +} + +export function _settleCctvFrame(token, src, ok) { + if (this.destroyed || !this._cctvFrame || token !== this._cctvFrameRequestToken) return; + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + this._cctvFramePreloader = null; + this._cctvFrame.dataset.loading = ''; + this._cctvFrameWrap?.classList.remove('loading'); + + const syncBadge = () => this._syncCctvSourceBadge( + this._cctvState?.activeCamera, + !!this._cctvState?.enabled && !!this.actions.isEnabled() + ); + + if (!ok) { + // Leave the element untouched — a settled frame stays on screen. + this._cctvFrame.dataset.error = 'true'; + syncBadge(); + return; + } + + this._cctvFrame.dataset.error = ''; + this._cctvFrame.src = src; + this._cctvFrame.classList.add('active'); + this._cctvFrameWrap?.classList.add('has-frame'); + syncBadge(); +} + +export function _syncCctvSourceBadge(activeCamera, enabled) { + if (!this._cctvSourceBadge) return; + if (!enabled || !activeCamera) { + this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; + this._cctvSourceBadge.dataset.frameState = 'idle'; + return; + } + const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); + if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { + this._cctvSourceBadge.textContent = 'FRAME · LOADING'; + this._cctvSourceBadge.dataset.frameState = 'loading'; + return; + } + if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { + this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; + this._cctvSourceBadge.dataset.frameState = 'error'; + return; + } + const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); + const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); + this._cctvSourceBadge.textContent = `${kind} · ${status}`; + this._cctvSourceBadge.dataset.frameState = 'ready'; +} diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js new file mode 100644 index 000000000..7f05706d4 --- /dev/null +++ b/src/ui/cctvPresentation.js @@ -0,0 +1,212 @@ +export function _calBadgeLabel(badge) { + switch (badge) { + case 'calibrated': return 'CALIBRATED'; + case 'curated': return 'CURATED'; + case 'raw-prior': return 'RAW PRIOR'; + default: return '--'; + } +} + +export function _renderCctvState(state) { + if (this.destroyed) return; + if (!state?.enabled || !this.actions.isEnabled() || state?.activeCameraId !== this._cctvState?.activeCameraId) { + this._calibrationEdit?.(false); + } + this._cctvState = state || null; + const cameras = state?.cameras || []; + const enabled = !!state?.enabled && !!this.actions.isEnabled(); + const activeId = state?.activeCameraId || ''; + const activeCamera = state?.activeCamera || null; + + // Auto-expand the panel when the active camera CHANGES to a new non-null + // id while the layer is enabled. Covers click-on-globe, panel controls, + // and voice (selectCamera/cycleCamera/focusNearest all notify through + // this subscription). The last-seen guard keeps routine notifications + // from re-expanding a panel the user deliberately collapsed, and timed + // auto-hop transitions only expand on the first activation so the panel + // does not pop open on every hop. + const effectiveActiveId = enabled ? (activeId || null) : null; + const isFirstActivation = this._lastSeenCctvActiveId === null; + if (effectiveActiveId + && effectiveActiveId !== this._lastSeenCctvActiveId + && (!state?.autoHop || isFirstActivation)) { + this.actions.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); + } + this._lastSeenCctvActiveId = effectiveActiveId; + + this._updateCctvSyncChip(state?.loading, enabled); + + if (this._cctvEnableBtn) { + this._cctvEnableBtn.classList.toggle('active', enabled); + this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; + } + + if (this._cctvSelect) { + const shouldRebuild = this._cctvSelect.options.length !== cameras.length + || cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); + if (shouldRebuild) { + this._cctvSelect.innerHTML = ''; + for (const camera of cameras) { + const option = document.createElement('option'); + option.value = camera.id; + option.textContent = `${camera.city} · ${camera.name}`; + this._cctvSelect.appendChild(option); + } + } + this._cctvSelect.disabled = !enabled || cameras.length === 0; + if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { + this._cctvSelect.value = activeId; + } else if (!activeId) { + this._cctvSelect.selectedIndex = -1; + } + } + + for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { + if (!btn) continue; + btn.disabled = !enabled || cameras.length === 0; + } + if (this._cctvFocusBtn) { + this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; + } + + if (this._cctvCoverageBtn) { + // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed + // (color-coded volumes). The click handler cycles; this renders. + const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); + this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); + this._cctvCoverageBtn.textContent = mode === 'viewshed' + ? 'VIEWSHED ON' + : mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; + this._cctvCoverageBtn.disabled = !enabled; + } + + if (this._cctvAutoHopBtn) { + const autoHop = !!state?.autoHop; + this._cctvAutoHopBtn.classList.toggle('active', autoHop); + this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; + this._cctvAutoHopBtn.disabled = !enabled; + } + + if (this._cctvProjectionBtn) { + const showProjection = state?.showProjection !== false; + this._cctvProjectionBtn.classList.toggle('active', showProjection); + this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; + this._cctvProjectionBtn.disabled = !enabled; + } + + if (this._cctvQualityChip) { + // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, + // no in-world tint): three states driven by cctv.js's deriveCalBadge, + // no client-side scoring math. Casing is unified via _calBadgeLabel so + // the chip and the meta line never drift onto different conventions. + // Save-gated persistence (viewshed design §3e): unsaved live edits show + // EDITED on top of whatever the persisted badge state is — SAVE CAL + // promotes to CALIBRATED, RESET CAL clears. + const badge = activeCamera?.calBadge || null; + const dirty = !!activeCamera?.calDirty; + this._cctvQualityChip.textContent = dirty + ? 'CAL · EDITED (UNSAVED)' + : `CAL · ${this._calBadgeLabel(badge)}`; + this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); + } + + this._syncCctvCalReadout(enabled, activeCamera); + + if (this._cctvMeta) { + if (activeCamera) { + const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; + const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; + const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; + const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; + this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; + } else if (cameras.length > 0) { + this._cctvMeta.textContent = enabled + ? `${cameras.length} cameras loaded · click a camera to activate` + : `${cameras.length} cameras loaded · enable CCTV to activate`; + } else { + this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; + } + } + + if (this._cctvFrame) { + const nextSrc = enabled ? activeCamera?.frameUrl : null; + const nextCameraId = enabled ? (activeCamera?.id || '') : ''; + const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; + const frameLoading = this._cctvFrame.dataset.loading === 'true'; + // A same-camera refresh waits for the current image to settle. Replacing + // src every 10 seconds can cancel a slow but healthy decode forever and + // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are + // immediate so navigation never waits on the prior camera's request. + if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { + this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); + } + if (!nextSrc) { + this._clearCctvFrame(); + } + } + + this._syncCctvSourceBadge(activeCamera, enabled); + this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); +} + +export function _typeCctvSummary(text) { + if (this.destroyed || !this._cctvSummary) return; + const nextText = String(text || '').trim() || 'No summary available.'; + if (nextText === this._lastCctvSummaryText) return; + this._lastCctvSummaryText = nextText; + + clearInterval(this._cctvSummaryTypingTimer); + this._cctvSummary.textContent = ''; + let idx = 0; + this._cctvSummaryTypingTimer = setInterval(() => { + if (this.destroyed) return; + idx += 3; + if (idx >= nextText.length) { + this._cctvSummary.textContent = nextText; + clearInterval(this._cctvSummaryTypingTimer); + this._cctvSummaryTypingTimer = null; + return; + } + this._cctvSummary.textContent = nextText.slice(0, idx); + }, 20); +} + +export function _updateCctvSyncChip(loading, enabled) { + if (this.destroyed) return; + if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; + const total = Number(loading?.total) || 0; + const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); + const busy = !!enabled && !!loading?.active && total > 0; + + if (busy) { + clearTimeout(this._cctvChipHideTimer); + this._cctvChipHideTimer = null; + this._cctvChipWasBusy = true; + this.actions.setSplitFlapText(this._cctvSyncLabel, 'loading frames'); + // The counter is left plain on purpose: it ticks every few frames + // during a grid load, and flapping it would read as a slot machine. + this._cctvSyncProgress.textContent = `${loaded}/${total}`; + this._cctvSyncChip.classList.add('visible'); + return; + } + + if (this._cctvChipWasBusy && enabled && total > 0) { + // Load just completed — flash the final count, then auto-hide. + this._cctvChipWasBusy = false; + this.actions.setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); + this._cctvSyncProgress.textContent = `${total}/${total}`; + this._cctvSyncChip.classList.add('visible'); + clearTimeout(this._cctvChipHideTimer); + this._cctvChipHideTimer = window.setTimeout(() => { + if (this.destroyed) return; + this._cctvChipHideTimer = null; + this._cctvSyncChip.classList.remove('visible'); + }, 1500); + return; + } + + if (!this._cctvChipHideTimer) { + this._cctvChipWasBusy = false; + this._cctvSyncChip.classList.remove('visible'); + } +} From 1a7e4d51d904c43be4501a53225bc3a40890a2c6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 00:38:01 -0700 Subject: [PATCH 33/92] style: format camera panel component modules --- scripts/qa-ui-disposal.mjs | 7 +- src/ui/cctvBindings.js | 249 +++++++++++---------- src/ui/cctvCalibration.js | 201 ++++++++++------- src/ui/cctvControls.js | 86 ++++++-- src/ui/cctvControls.test.mjs | 116 +++++++--- src/ui/cctvFrames.js | 175 ++++++++------- src/ui/cctvPresentation.js | 408 +++++++++++++++++++---------------- 7 files changed, 736 insertions(+), 506 deletions(-) diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 1cfa077c4..9aabdd75c 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -82,10 +82,13 @@ try { observersReleased: observed.every( (name) => counts[name] === 1 && ui[name] === null, ), - subscriptionReleased: counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null, + subscriptionReleased: + counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null, resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null, controlsReleased: - ui._radioControls.destroyed && ui._locationControls.destroyed && ui._cctvControls.destroyed, + ui._radioControls.destroyed && + ui._locationControls.destroyed && + ui._cctvControls.destroyed, idempotent: once === JSON.stringify(counts), }; } finally { diff --git a/src/ui/cctvBindings.js b/src/ui/cctvBindings.js index 5c70c2c65..612e4eb37 100644 --- a/src/ui/cctvBindings.js +++ b/src/ui/cctvBindings.js @@ -1,130 +1,155 @@ export function _initCctvPanel() { - if (!this._cctvPanel) return; + if (!this._cctvPanel) return; - this.listen(this._cctvEnableBtn, 'click', async () => { - this._actionGeneration++; - await this.actions.toggleEnabled(); - }); + this.listen(this._cctvEnableBtn, 'click', async () => { + this._actionGeneration++; + await this.actions.toggleEnabled(); + }); - this.listen(this._cctvNearestBtn, 'click', async () => { - const generation = ++this._actionGeneration; - const activeId = this._cctvState?.activeCameraId; - if (!await this.actions.toggleEnabled(true)) return; - if (this.destroyed || generation !== this._actionGeneration - || !this.actions.isEnabled() - || (activeId && activeId !== this._cctvState?.activeCameraId)) return; - this.actions.runExplicitFocus( - () => this.cctv.focusNearest({ focus: false }), - (cameraId) => this.cctv.focusCamera(cameraId, 1.8), - ); - }); + this.listen(this._cctvNearestBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!(await this.actions.toggleEnabled(true))) return; + if ( + this.destroyed || + generation !== this._actionGeneration || + !this.actions.isEnabled() || + (activeId && activeId !== this._cctvState?.activeCameraId) + ) + return; + this.actions.runExplicitFocus( + () => this.cctv.focusNearest({ focus: false }), + (cameraId) => this.cctv.focusCamera(cameraId, 1.8), + ); + }); - this.listen(this._cctvPrevBtn, 'click', async () => { - const generation = ++this._actionGeneration; - const activeId = this._cctvState?.activeCameraId; - if (!await this.actions.toggleEnabled(true)) return; - if (this.destroyed || generation !== this._actionGeneration - || !this.actions.isEnabled() - || (activeId && activeId !== this._cctvState?.activeCameraId)) return; - this.actions.runExplicitFocus( - () => this.cctv.cycleCamera(-1), - (cameraId) => this.cctv.focusCamera(cameraId, 1.4), - ); - }); + this.listen(this._cctvPrevBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!(await this.actions.toggleEnabled(true))) return; + if ( + this.destroyed || + generation !== this._actionGeneration || + !this.actions.isEnabled() || + (activeId && activeId !== this._cctvState?.activeCameraId) + ) + return; + this.actions.runExplicitFocus( + () => this.cctv.cycleCamera(-1), + (cameraId) => this.cctv.focusCamera(cameraId, 1.4), + ); + }); - this.listen(this._cctvNextBtn, 'click', async () => { - const generation = ++this._actionGeneration; - const activeId = this._cctvState?.activeCameraId; - if (!await this.actions.toggleEnabled(true)) return; - if (this.destroyed || generation !== this._actionGeneration - || !this.actions.isEnabled() - || (activeId && activeId !== this._cctvState?.activeCameraId)) return; - this.actions.runExplicitFocus( - () => this.cctv.cycleCamera(1), - (cameraId) => this.cctv.focusCamera(cameraId, 1.4), - ); - }); + this.listen(this._cctvNextBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!(await this.actions.toggleEnabled(true))) return; + if ( + this.destroyed || + generation !== this._actionGeneration || + !this.actions.isEnabled() || + (activeId && activeId !== this._cctvState?.activeCameraId) + ) + return; + this.actions.runExplicitFocus( + () => this.cctv.cycleCamera(1), + (cameraId) => this.cctv.focusCamera(cameraId, 1.4), + ); + }); - this.listen(this._cctvSelect, 'change', async () => { - const generation = ++this._actionGeneration; - const activeId = this._cctvState?.activeCameraId; - const cameraId = this._cctvSelect.value; - if (!cameraId) return; - if (!await this.actions.toggleEnabled(true)) return; - if (this.destroyed || generation !== this._actionGeneration - || !this.actions.isEnabled() - || (activeId && activeId !== this._cctvState?.activeCameraId)) return; - // Picking a camera from the dropdown flies to it. The catalog spans - // three metros, so a bare selection used to leave the view in the old - // city with a camera active thousands of km away. - this.actions.runExplicitFocus( - () => (this.cctv.selectCamera(cameraId) ? cameraId : null), - (selectedId) => this.cctv.focusCamera(selectedId, 2.2), - ); - this.actions.setParams({ selectedCameraId: cameraId }, { origin: 'user' }); - }); + this.listen(this._cctvSelect, 'change', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + const cameraId = this._cctvSelect.value; + if (!cameraId) return; + if (!(await this.actions.toggleEnabled(true))) return; + if ( + this.destroyed || + generation !== this._actionGeneration || + !this.actions.isEnabled() || + (activeId && activeId !== this._cctvState?.activeCameraId) + ) + return; + // Picking a camera from the dropdown flies to it. The catalog spans + // three metros, so a bare selection used to leave the view in the old + // city with a camera active thousands of km away. + this.actions.runExplicitFocus( + () => (this.cctv.selectCamera(cameraId) ? cameraId : null), + (selectedId) => this.cctv.focusCamera(selectedId, 2.2), + ); + this.actions.setParams({ selectedCameraId: cameraId }, { origin: 'user' }); + }); - this.listen(this._cctvFocusBtn, 'click', async () => { - const generation = ++this._actionGeneration; - const activeId = this._cctvState?.activeCameraId; - const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; - if (!selected) return; - if (!await this.actions.toggleEnabled(true)) return; - if (this.destroyed || generation !== this._actionGeneration - || !this.actions.isEnabled() - || (activeId && activeId !== this._cctvState?.activeCameraId)) return; - this.actions.runExplicitFocus( - () => selected, - (cameraId) => this.cctv.focusCamera(cameraId, 1.9), - ); - this.actions.setParams({ selectedCameraId: selected }, { origin: 'user' }); - }); + this.listen(this._cctvFocusBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; + if (!selected) return; + if (!(await this.actions.toggleEnabled(true))) return; + if ( + this.destroyed || + generation !== this._actionGeneration || + !this.actions.isEnabled() || + (activeId && activeId !== this._cctvState?.activeCameraId) + ) + return; + this.actions.runExplicitFocus( + () => selected, + (cameraId) => this.cctv.focusCamera(cameraId, 1.9), + ); + this.actions.setParams({ selectedCameraId: selected }, { origin: 'user' }); + }); - this.listen(this._cctvCoverageBtn, 'click', () => { - const current = this._cctvState?.coverageMode - || (this._cctvState?.showCoverage ? 'on' : 'off'); - const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; - this.actions.setParams({ coverageMode: next }, { origin: 'user' }); - }); + this.listen(this._cctvCoverageBtn, 'click', () => { + const current = + this._cctvState?.coverageMode || + (this._cctvState?.showCoverage ? 'on' : 'off'); + const next = + current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; + this.actions.setParams({ coverageMode: next }, { origin: 'user' }); + }); - this.listen(this._cctvAutoHopBtn, 'click', () => { - const current = !!this._cctvState?.autoHop; - this.actions.setParams({ autoHop: !current }, { origin: 'user' }); - }); + this.listen(this._cctvAutoHopBtn, 'click', () => { + const current = !!this._cctvState?.autoHop; + this.actions.setParams({ autoHop: !current }, { origin: 'user' }); + }); - this.listen(this._cctvProjectionBtn, 'click', () => { - const current = this._cctvState?.showProjection !== false; - this.actions.setParams({ showProjection: !current }, { origin: 'user' }); - }); + this.listen(this._cctvProjectionBtn, 'click', () => { + const current = this._cctvState?.showProjection !== false; + this.actions.setParams({ showProjection: !current }, { origin: 'user' }); + }); - this.listen(this._cctvAdjustBtn, 'click', () => { - const current = !!this._cctvState?.calibrationMode; - this.actions.setParams({ calibrationMode: !current }, { origin: 'user' }); - }); + this.listen(this._cctvAdjustBtn, 'click', () => { + const current = !!this._cctvState?.calibrationMode; + this.actions.setParams({ calibrationMode: !current }, { origin: 'user' }); + }); - // Click-to-edit pose readout: each chip swaps to a number input; Enter or - // blur commits (converted to a calibration offset against basePose), - // Escape cancels. Delegated so re-renders never re-bind. - this.listen(this._cctvCalReadout, 'click', (event) => { - const chip = event.target.closest?.('.cctv-cal-value'); - if (!chip || chip.disabled || chip.querySelector('input')) return; - this._beginCctvCalValueEdit(chip); - }); + // Click-to-edit pose readout: each chip swaps to a number input; Enter or + // blur commits (converted to a calibration offset against basePose), + // Escape cancels. Delegated so re-renders never re-bind. + this.listen(this._cctvCalReadout, 'click', (event) => { + const chip = event.target.closest?.('.cctv-cal-value'); + if (!chip || chip.disabled || chip.querySelector('input')) return; + this._beginCctvCalValueEdit(chip); + }); - this.listen(this._cctvCalibSaveBtn, 'click', () => { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this.actions.setParams) return; - this.actions.setParams({ + this.listen(this._cctvCalibSaveBtn, 'click', () => { + const cameraId = this._activeCctvCameraId(); + if (!cameraId || !this.actions.setParams) return; + this.actions.setParams( + { selectedCameraId: cameraId, calibration: { cameraId, save: true }, - }, { origin: 'user' }); - this.actions.showToast('CCTV calibration saved'); - }); + }, + { origin: 'user' }, + ); + this.actions.showToast('CCTV calibration saved'); + }); - this.listen(this._cctvCalibResetBtn, 'click', () => { - this._resetCctvCalibration(); - }); + this.listen(this._cctvCalibResetBtn, 'click', () => { + this._resetCctvCalibration(); + }); - this._renderCctvState(null); - this.actions.syncViewport(); + this._renderCctvState(null); + this.actions.syncViewport(); } diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js index 6d2d4487b..533c2615d 100644 --- a/src/ui/cctvCalibration.js +++ b/src/ui/cctvCalibration.js @@ -10,131 +10,176 @@ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; */ const CCTV_CAL_FIELDS = { heading: { - label: 'HDG', unit: '°', decimals: 1, + label: 'HDG', + unit: '°', + decimals: 1, get: (cam) => cam.headingDeg, - toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), + toPatch: (value, base) => ({ + headingDeg: signedNormalizeDeg(value - base.headingDeg), + }), }, pitch: { - label: 'PITCH', unit: '°', decimals: 1, + label: 'PITCH', + unit: '°', + decimals: 1, get: (cam) => cam.pitchDeg, toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), }, fov: { - label: 'FOV', unit: '°', decimals: 0, + label: 'FOV', + unit: '°', + decimals: 0, get: (cam) => cam.fovDeg, toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), }, range: { - label: 'RANGE', unit: 'm', decimals: 0, + label: 'RANGE', + unit: 'm', + decimals: 0, get: (cam) => cam.rangeM, - toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), + toPatch: (value, base) => ({ + rangeScale: base.rangeM > 0 ? value / base.rangeM : 1, + }), }, height: { - label: 'HGT', unit: 'm', decimals: 0, + label: 'HGT', + unit: 'm', + decimals: 0, get: (cam) => cam.mountHeightM, toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), }, north: { - label: 'ΔN', unit: 'm', decimals: 1, + label: 'ΔN', + unit: 'm', + decimals: 1, get: (cam) => cam.calibration?.offsetNorthM || 0, toPatch: (value) => ({ offsetNorthM: value }), }, east: { - label: 'ΔE', unit: 'm', decimals: 1, + label: 'ΔE', + unit: 'm', + decimals: 1, get: (cam) => cam.calibration?.offsetEastM || 0, toPatch: (value) => ({ offsetEastM: value }), }, }; export function _activeCctvCameraId() { - return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; + return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; } export function _resetCctvCalibration() { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this.actions.setParams) return; - this.actions.setParams({ + const cameraId = this._activeCctvCameraId(); + if (!cameraId || !this.actions.setParams) return; + this.actions.setParams( + { selectedCameraId: cameraId, calibration: { cameraId, reset: true, }, - }, { origin: 'user' }); - this.actions.showToast('CCTV calibration reset'); + }, + { origin: 'user' }, + ); + this.actions.showToast('CCTV calibration reset'); } export function _beginCctvCalValueEdit(chip) { - if (this.destroyed) return; - this._calibrationEdit?.(false); - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - const activeCamera = this._cctvState?.activeCamera; - if (!field || !activeCamera?.basePose) return; - const cameraId = this._activeCctvCameraId(); - const basePose = { ...activeCamera.basePose }; - const editListeners = new AbortController(); - const startValue = field.get(activeCamera); - const input = document.createElement('input'); - input.type = 'number'; - input.step = field.decimals > 0 ? '0.1' : '1'; - input.value = Number(startValue).toFixed(field.decimals); - input.className = 'cctv-cal-input'; - chip.textContent = `${field.label} `; - chip.appendChild(input); - input.focus(); - input.select(); + if (this.destroyed) return; + this._calibrationEdit?.(false); + const field = CCTV_CAL_FIELDS[chip.dataset.calField]; + const activeCamera = this._cctvState?.activeCamera; + if (!field || !activeCamera?.basePose) return; + const cameraId = this._activeCctvCameraId(); + const basePose = { ...activeCamera.basePose }; + const editListeners = new AbortController(); + const startValue = field.get(activeCamera); + const input = document.createElement('input'); + input.type = 'number'; + input.step = field.decimals > 0 ? '0.1' : '1'; + input.value = Number(startValue).toFixed(field.decimals); + input.className = 'cctv-cal-input'; + chip.textContent = `${field.label} `; + chip.appendChild(input); + input.focus(); + input.select(); - let finished = false; - const finish = (commit) => { - if (finished) return; - finished = true; - editListeners.abort(); - this._calibrationEdit = null; - const typed = parseFloat(input.value); - input.remove(); - if (commit && !this.destroyed && Number.isFinite(typed) - && this._cctvState?.enabled && this.actions.isEnabled() - && cameraId === this._activeCctvCameraId()) { - if (cameraId && this.actions.setParams) { - this.actions.setParams({ + let finished = false; + const finish = (commit) => { + if (finished) return; + finished = true; + editListeners.abort(); + this._calibrationEdit = null; + const typed = parseFloat(input.value); + input.remove(); + if ( + commit && + !this.destroyed && + Number.isFinite(typed) && + this._cctvState?.enabled && + this.actions.isEnabled() && + cameraId === this._activeCctvCameraId() + ) { + if (cameraId && this.actions.setParams) { + this.actions.setParams( + { selectedCameraId: cameraId, calibration: { cameraId, patch: field.toPatch(typed, basePose) }, - }, { origin: 'user' }); - return; // re-render restores the chip text from fresh state - } + }, + { origin: 'user' }, + ); + return; // re-render restores the chip text from fresh state } - this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); - }; - this._calibrationEdit = finish; - input.addEventListener('keydown', (event) => { + } + this._syncCctvCalReadout( + !!this._cctvState?.enabled, + this._cctvState?.activeCamera || null, + ); + }; + this._calibrationEdit = finish; + input.addEventListener( + 'keydown', + (event) => { if (event.key === 'Enter') finish(true); - else if (event.key === 'Escape') { event.preventDefault(); finish(false); } + else if (event.key === 'Escape') { + event.preventDefault(); + finish(false); + } event.stopPropagation(); - }, { signal: editListeners.signal }); - input.addEventListener('blur', () => finish(true), { signal: editListeners.signal }); - input.addEventListener('click', (event) => event.stopPropagation(), { signal: editListeners.signal }); + }, + { signal: editListeners.signal }, + ); + input.addEventListener('blur', () => finish(true), { + signal: editListeners.signal, + }); + input.addEventListener('click', (event) => event.stopPropagation(), { + signal: editListeners.signal, + }); } export function _syncCctvCalReadout(enabled, activeCamera) { - const canCalibrate = !!enabled && !!activeCamera; - if (this._cctvAdjustBtn) { - const adjustOn = !!this._cctvState?.calibrationMode; - this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); - this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; - this._cctvAdjustBtn.disabled = !canCalibrate; - } - if (this._cctvCalReadout) { - for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { - if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - if (!field) continue; - const value = canCalibrate ? field.get(activeCamera) : null; - chip.textContent = Number.isFinite(value) - ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` - : `${field.label} --`; - chip.disabled = !canCalibrate; - } - } - for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { - if (el) el.disabled = !canCalibrate; + const canCalibrate = !!enabled && !!activeCamera; + if (this._cctvAdjustBtn) { + const adjustOn = !!this._cctvState?.calibrationMode; + this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); + this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; + this._cctvAdjustBtn.disabled = !canCalibrate; + } + if (this._cctvCalReadout) { + for (const chip of this._cctvCalReadout.querySelectorAll( + '.cctv-cal-value', + )) { + if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber + const field = CCTV_CAL_FIELDS[chip.dataset.calField]; + if (!field) continue; + const value = canCalibrate ? field.get(activeCamera) : null; + chip.textContent = Number.isFinite(value) + ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` + : `${field.label} --`; + chip.disabled = !canCalibrate; } + } + for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { + if (el) el.disabled = !canCalibrate; + } } diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 46805ad69..75ab0be98 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -1,6 +1,21 @@ -import { _clearCctvFrame, _queueCctvFrame, _settleCctvFrame, _syncCctvSourceBadge } from './cctvFrames.js'; -import { _activeCctvCameraId, _resetCctvCalibration, _beginCctvCalValueEdit, _syncCctvCalReadout } from './cctvCalibration.js'; -import { _calBadgeLabel, _renderCctvState, _typeCctvSummary, _updateCctvSyncChip } from './cctvPresentation.js'; +import { + _clearCctvFrame, + _queueCctvFrame, + _settleCctvFrame, + _syncCctvSourceBadge, +} from './cctvFrames.js'; +import { + _activeCctvCameraId, + _resetCctvCalibration, + _beginCctvCalValueEdit, + _syncCctvCalReadout, +} from './cctvCalibration.js'; +import { + _calBadgeLabel, + _renderCctvState, + _typeCctvSummary, + _updateCctvSyncChip, +} from './cctvPresentation.js'; import { _initCctvPanel } from './cctvBindings.js'; /** Own camera-panel interaction and presentation; receive the camera port and application actions. */ @@ -25,29 +40,62 @@ export class CctvControls { this._initCctvPanel(); } listen(target, type, handler, options = {}) { - target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal }); + target?.addEventListener(type, handler, { + ...options, + signal: this.listeners.signal, + }); + } + getState() { + return this._cctvState; } - getState() { return this._cctvState; } connect() { this._cctvUnsubscribe?.(); this._cctvUnsubscribe = null; if (this.destroyed) return; - this._cctvUnsubscribe = this.cctv.subscribe?.((state) => this._renderCctvState(state)); + this._cctvUnsubscribe = this.cctv.subscribe?.((state) => + this._renderCctvState(state), + ); if (this.cctv.getUIState) this._renderCctvState(this.cctv.getUIState()); } - _clearCctvFrame(...args) { return _clearCctvFrame.call(this, ...args); } - _queueCctvFrame(...args) { return _queueCctvFrame.call(this, ...args); } - _settleCctvFrame(...args) { return _settleCctvFrame.call(this, ...args); } - _syncCctvSourceBadge(...args) { return _syncCctvSourceBadge.call(this, ...args); } - _activeCctvCameraId(...args) { return _activeCctvCameraId.call(this, ...args); } - _resetCctvCalibration(...args) { return _resetCctvCalibration.call(this, ...args); } - _beginCctvCalValueEdit(...args) { return _beginCctvCalValueEdit.call(this, ...args); } - _syncCctvCalReadout(...args) { return _syncCctvCalReadout.call(this, ...args); } - _calBadgeLabel(...args) { return _calBadgeLabel.call(this, ...args); } - _renderCctvState(...args) { return _renderCctvState.call(this, ...args); } - _typeCctvSummary(...args) { return _typeCctvSummary.call(this, ...args); } - _updateCctvSyncChip(...args) { return _updateCctvSyncChip.call(this, ...args); } - _initCctvPanel(...args) { return _initCctvPanel.call(this, ...args); } + _clearCctvFrame(...args) { + return _clearCctvFrame.call(this, ...args); + } + _queueCctvFrame(...args) { + return _queueCctvFrame.call(this, ...args); + } + _settleCctvFrame(...args) { + return _settleCctvFrame.call(this, ...args); + } + _syncCctvSourceBadge(...args) { + return _syncCctvSourceBadge.call(this, ...args); + } + _activeCctvCameraId(...args) { + return _activeCctvCameraId.call(this, ...args); + } + _resetCctvCalibration(...args) { + return _resetCctvCalibration.call(this, ...args); + } + _beginCctvCalValueEdit(...args) { + return _beginCctvCalValueEdit.call(this, ...args); + } + _syncCctvCalReadout(...args) { + return _syncCctvCalReadout.call(this, ...args); + } + _calBadgeLabel(...args) { + return _calBadgeLabel.call(this, ...args); + } + _renderCctvState(...args) { + return _renderCctvState.call(this, ...args); + } + _typeCctvSummary(...args) { + return _typeCctvSummary.call(this, ...args); + } + _updateCctvSyncChip(...args) { + return _updateCctvSyncChip.call(this, ...args); + } + _initCctvPanel(...args) { + return _initCctvPanel.call(this, ...args); + } destroy() { if (this.destroyed) return; this.destroyed = true; diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 8d93a7ae1..5ab5a3280 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -5,24 +5,43 @@ import { CctvControls } from './cctvControls.js'; function element() { const classes = new Set(); return { - dataset: {}, src: '', - removeAttribute(name) { if (name === 'src') this.src = ''; }, + dataset: {}, + src: '', + removeAttribute(name) { + if (name === 'src') this.src = ''; + }, classList: { - add(...values) { values.forEach(value => classes.add(value)); }, - remove(...values) { values.forEach(value => classes.delete(value)); }, - contains(value) { return classes.has(value); }, - toggle(value, enabled) { if (enabled) classes.add(value); else classes.delete(value); }, + add(...values) { + values.forEach((value) => classes.add(value)); + }, + remove(...values) { + values.forEach((value) => classes.delete(value)); + }, + contains(value) { + return classes.has(value); + }, + toggle(value, enabled) { + if (enabled) classes.add(value); + else classes.delete(value); + }, }, }; } function fixture(t) { const prior = globalThis.Image; const requests = []; - globalThis.Image = class { constructor() { requests.push(this); } }; - t.after(() => { globalThis.Image = prior; }); + globalThis.Image = class { + constructor() { + requests.push(this); + } + }; + t.after(() => { + globalThis.Image = prior; + }); const controls = new CctvControls({ elements: { _cctvFrame: element(), _cctvFrameWrap: element() }, - cctv: {}, actions: { isEnabled: () => true }, + cctv: {}, + actions: { isEnabled: () => true }, }); t.after(() => controls.destroy()); return { controls, requests }; @@ -56,7 +75,9 @@ test('failed refresh preserves settled pixels, but changing cameras clears them' test('destroy invalidates image callbacks and releases each subscription once', (t) => { const { controls, requests } = fixture(t); let unsubscribed = 0; - controls.cctv.subscribe = () => () => { unsubscribed++; }; + controls.cctv.subscribe = () => () => { + unsubscribed++; + }; controls.connect(); controls.connect(); assert.equal(unsubscribed, 1); @@ -78,27 +99,46 @@ function calibrationFixture(t) { const { controls } = fixture(t); const prior = globalThis.document; const inputs = []; - globalThis.document = { createElement() { - const input = new EventTarget(); - Object.assign(input, { focus() {}, select() {}, remove() { this.parent.input = null; } }); - inputs.push(input); - return input; - } }; - t.after(() => { globalThis.document = prior; }); + globalThis.document = { + createElement() { + const input = new EventTarget(); + Object.assign(input, { + focus() {}, + select() {}, + remove() { + this.parent.input = null; + }, + }); + inputs.push(input); + return input; + }, + }; + t.after(() => { + globalThis.document = prior; + }); const chip = { - dataset: { calField: 'heading' }, textContent: '', input: null, - appendChild(input) { this.input = input; input.parent = this; }, - querySelector() { return this.input; }, + dataset: { calField: 'heading' }, + textContent: '', + input: null, + appendChild(input) { + this.input = input; + input.parent = this; + }, + querySelector() { + return this.input; + }, }; const patches = []; controls.actions.setParams = (params) => patches.push(params); controls.actions.setPanelCollapsed = () => {}; controls._cctvCalReadout = { querySelectorAll: () => [chip] }; controls._cctvState = { - enabled: true, activeCameraId: 'a', + enabled: true, + activeCameraId: 'a', activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } }, }; - const key = (name) => Object.assign(new Event('keydown', { cancelable: true }), { key: name }); + const key = (name) => + Object.assign(new Event('keydown', { cancelable: true }), { key: name }); return { controls, chip, inputs, patches, key }; } @@ -109,7 +149,12 @@ test('calibration commits against the captured camera base and releases its edit controls._cctvState.activeCamera.basePose.headingDeg = 60; inputs[0].dispatchEvent(key('Enter')); inputs[0].dispatchEvent(new Event('blur')); - assert.deepEqual(patches, [{ selectedCameraId: 'a', calibration: { cameraId: 'a', patch: { headingDeg: 80 } } }]); + assert.deepEqual(patches, [ + { + selectedCameraId: 'a', + calibration: { cameraId: 'a', patch: { headingDeg: 80 } }, + }, + ]); assert.equal(controls._calibrationEdit, null); }); @@ -117,7 +162,11 @@ test('a camera switch cancels calibration before old blur can change the new cam const { controls, chip, inputs, patches } = calibrationFixture(t); controls._beginCctvCalValueEdit(chip); inputs[0].value = '100'; - controls._renderCctvState({ enabled: true, activeCameraId: 'b', activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } } }); + controls._renderCctvState({ + enabled: true, + activeCameraId: 'b', + activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } }, + }); inputs[0].dispatchEvent(new Event('blur')); assert.deepEqual(patches, []); assert.equal(chip.input, null); @@ -145,17 +194,26 @@ test('disposing during camera enable prevents the delayed focus and future click let enables = 0; let focuses = 0; const controls = new CctvControls({ - elements: { _cctvPanel: {}, _cctvNextBtn: button }, cctv: {}, + elements: { _cctvPanel: {}, _cctvNextBtn: button }, + cctv: {}, actions: { - isEnabled: () => true, syncViewport() {}, - toggleEnabled() { enables++; return new Promise(resolve => { resolveEnable = resolve; }); }, - runExplicitFocus() { focuses++; }, + isEnabled: () => true, + syncViewport() {}, + toggleEnabled() { + enables++; + return new Promise((resolve) => { + resolveEnable = resolve; + }); + }, + runExplicitFocus() { + focuses++; + }, }, }); button.dispatchEvent(new Event('click')); controls.destroy(); resolveEnable(true); - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); button.dispatchEvent(new Event('click')); assert.equal(enables, 1); assert.equal(focuses, 0); diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index fcf4ff379..7dce71dd7 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -1,102 +1,111 @@ export function _clearCctvFrame() { - this._cctvFrameRequestToken += 1; - if (this._cctvFramePreloader) { - this._cctvFramePreloader.onload = null; - this._cctvFramePreloader.onerror = null; - } - this._cctvFramePreloader = null; - if (this._cctvFrame) { - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrame.dataset.cameraId = ''; - this._cctvFrame.dataset.currentSrc = ''; - this._cctvFrame.dataset.loading = ''; - this._cctvFrame.dataset.error = ''; - } - this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); + this._cctvFrameRequestToken += 1; + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + this._cctvFramePreloader = null; + if (this._cctvFrame) { + this._cctvFrame.classList.remove('active'); + this._cctvFrame.removeAttribute('src'); + this._cctvFrame.dataset.cameraId = ''; + this._cctvFrame.dataset.currentSrc = ''; + this._cctvFrame.dataset.loading = ''; + this._cctvFrame.dataset.error = ''; + } + this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); } export function _queueCctvFrame(src, cameraId, cameraChanged) { - if (this.destroyed || !this._cctvFrame || !src) return; + if (this.destroyed || !this._cctvFrame || !src) return; - if (cameraChanged) { - // A different camera gets an honest acquisition state. Never retain - // the prior camera's pixels under the newly selected metadata. - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrameWrap?.classList.remove('has-frame'); - } + if (cameraChanged) { + // A different camera gets an honest acquisition state. Never retain + // the prior camera's pixels under the newly selected metadata. + this._cctvFrame.classList.remove('active'); + this._cctvFrame.removeAttribute('src'); + this._cctvFrameWrap?.classList.remove('has-frame'); + } - if (this._cctvFramePreloader) { - this._cctvFramePreloader.onload = null; - this._cctvFramePreloader.onerror = null; - } - const token = ++this._cctvFrameRequestToken; - this._cctvFrame.dataset.cameraId = cameraId; - this._cctvFrame.dataset.currentSrc = src; - this._cctvFrame.dataset.loading = 'true'; - this._cctvFrame.dataset.error = ''; - this._cctvFrameWrap?.classList.toggle( - 'loading', - !this._cctvFrameWrap?.classList.contains('has-frame') - ); + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + const token = ++this._cctvFrameRequestToken; + this._cctvFrame.dataset.cameraId = cameraId; + this._cctvFrame.dataset.currentSrc = src; + this._cctvFrame.dataset.loading = 'true'; + this._cctvFrame.dataset.error = ''; + this._cctvFrameWrap?.classList.toggle( + 'loading', + !this._cctvFrameWrap?.classList.contains('has-frame'), + ); - const preloader = new Image(); - this._cctvFramePreloader = preloader; - preloader.onload = () => this._settleCctvFrame(token, src, true); - preloader.onerror = () => this._settleCctvFrame(token, src, false); - preloader.src = src; + const preloader = new Image(); + this._cctvFramePreloader = preloader; + preloader.onload = () => this._settleCctvFrame(token, src, true); + preloader.onerror = () => this._settleCctvFrame(token, src, false); + preloader.src = src; } export function _settleCctvFrame(token, src, ok) { - if (this.destroyed || !this._cctvFrame || token !== this._cctvFrameRequestToken) return; - if (this._cctvFramePreloader) { - this._cctvFramePreloader.onload = null; - this._cctvFramePreloader.onerror = null; - } - this._cctvFramePreloader = null; - this._cctvFrame.dataset.loading = ''; - this._cctvFrameWrap?.classList.remove('loading'); + if ( + this.destroyed || + !this._cctvFrame || + token !== this._cctvFrameRequestToken + ) + return; + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + this._cctvFramePreloader = null; + this._cctvFrame.dataset.loading = ''; + this._cctvFrameWrap?.classList.remove('loading'); - const syncBadge = () => this._syncCctvSourceBadge( + const syncBadge = () => + this._syncCctvSourceBadge( this._cctvState?.activeCamera, - !!this._cctvState?.enabled && !!this.actions.isEnabled() + !!this._cctvState?.enabled && !!this.actions.isEnabled(), ); - if (!ok) { - // Leave the element untouched — a settled frame stays on screen. - this._cctvFrame.dataset.error = 'true'; - syncBadge(); - return; - } - - this._cctvFrame.dataset.error = ''; - this._cctvFrame.src = src; - this._cctvFrame.classList.add('active'); - this._cctvFrameWrap?.classList.add('has-frame'); + if (!ok) { + // Leave the element untouched — a settled frame stays on screen. + this._cctvFrame.dataset.error = 'true'; syncBadge(); + return; + } + + this._cctvFrame.dataset.error = ''; + this._cctvFrame.src = src; + this._cctvFrame.classList.add('active'); + this._cctvFrameWrap?.classList.add('has-frame'); + syncBadge(); } export function _syncCctvSourceBadge(activeCamera, enabled) { - if (!this._cctvSourceBadge) return; - if (!enabled || !activeCamera) { - this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; - this._cctvSourceBadge.dataset.frameState = 'idle'; - return; - } - const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); - if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · LOADING'; - this._cctvSourceBadge.dataset.frameState = 'loading'; - return; - } - if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; - this._cctvSourceBadge.dataset.frameState = 'error'; - return; - } - const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); - const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); - this._cctvSourceBadge.textContent = `${kind} · ${status}`; - this._cctvSourceBadge.dataset.frameState = 'ready'; + if (!this._cctvSourceBadge) return; + if (!enabled || !activeCamera) { + this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; + this._cctvSourceBadge.dataset.frameState = 'idle'; + return; + } + const hasDisplayedFrame = + this._cctvFrameWrap?.classList.contains('has-frame'); + if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { + this._cctvSourceBadge.textContent = 'FRAME · LOADING'; + this._cctvSourceBadge.dataset.frameState = 'loading'; + return; + } + if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { + this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; + this._cctvSourceBadge.dataset.frameState = 'error'; + return; + } + const kind = String( + activeCamera.sourceKind || activeCamera.feedType || 'unknown', + ).toUpperCase(); + const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); + this._cctvSourceBadge.textContent = `${kind} · ${status}`; + this._cctvSourceBadge.dataset.frameState = 'ready'; } diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 7f05706d4..0818c1209 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -1,212 +1,254 @@ export function _calBadgeLabel(badge) { - switch (badge) { - case 'calibrated': return 'CALIBRATED'; - case 'curated': return 'CURATED'; - case 'raw-prior': return 'RAW PRIOR'; - default: return '--'; - } + switch (badge) { + case 'calibrated': + return 'CALIBRATED'; + case 'curated': + return 'CURATED'; + case 'raw-prior': + return 'RAW PRIOR'; + default: + return '--'; + } } export function _renderCctvState(state) { - if (this.destroyed) return; - if (!state?.enabled || !this.actions.isEnabled() || state?.activeCameraId !== this._cctvState?.activeCameraId) { - this._calibrationEdit?.(false); - } - this._cctvState = state || null; - const cameras = state?.cameras || []; - const enabled = !!state?.enabled && !!this.actions.isEnabled(); - const activeId = state?.activeCameraId || ''; - const activeCamera = state?.activeCamera || null; + if (this.destroyed) return; + if ( + !state?.enabled || + !this.actions.isEnabled() || + state?.activeCameraId !== this._cctvState?.activeCameraId + ) { + this._calibrationEdit?.(false); + } + this._cctvState = state || null; + const cameras = state?.cameras || []; + const enabled = !!state?.enabled && !!this.actions.isEnabled(); + const activeId = state?.activeCameraId || ''; + const activeCamera = state?.activeCamera || null; - // Auto-expand the panel when the active camera CHANGES to a new non-null - // id while the layer is enabled. Covers click-on-globe, panel controls, - // and voice (selectCamera/cycleCamera/focusNearest all notify through - // this subscription). The last-seen guard keeps routine notifications - // from re-expanding a panel the user deliberately collapsed, and timed - // auto-hop transitions only expand on the first activation so the panel - // does not pop open on every hop. - const effectiveActiveId = enabled ? (activeId || null) : null; - const isFirstActivation = this._lastSeenCctvActiveId === null; - if (effectiveActiveId - && effectiveActiveId !== this._lastSeenCctvActiveId - && (!state?.autoHop || isFirstActivation)) { - this.actions.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); - } - this._lastSeenCctvActiveId = effectiveActiveId; + // Auto-expand the panel when the active camera CHANGES to a new non-null + // id while the layer is enabled. Covers click-on-globe, panel controls, + // and voice (selectCamera/cycleCamera/focusNearest all notify through + // this subscription). The last-seen guard keeps routine notifications + // from re-expanding a panel the user deliberately collapsed, and timed + // auto-hop transitions only expand on the first activation so the panel + // does not pop open on every hop. + const effectiveActiveId = enabled ? activeId || null : null; + const isFirstActivation = this._lastSeenCctvActiveId === null; + if ( + effectiveActiveId && + effectiveActiveId !== this._lastSeenCctvActiveId && + (!state?.autoHop || isFirstActivation) + ) { + this.actions.setPanelCollapsed('cctv-panel', false, { + explicit: Boolean(state?.explicitSelection), + }); + } + this._lastSeenCctvActiveId = effectiveActiveId; - this._updateCctvSyncChip(state?.loading, enabled); + this._updateCctvSyncChip(state?.loading, enabled); - if (this._cctvEnableBtn) { - this._cctvEnableBtn.classList.toggle('active', enabled); - this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; - } + if (this._cctvEnableBtn) { + this._cctvEnableBtn.classList.toggle('active', enabled); + this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; + } - if (this._cctvSelect) { - const shouldRebuild = this._cctvSelect.options.length !== cameras.length - || cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); - if (shouldRebuild) { - this._cctvSelect.innerHTML = ''; - for (const camera of cameras) { - const option = document.createElement('option'); - option.value = camera.id; - option.textContent = `${camera.city} · ${camera.name}`; - this._cctvSelect.appendChild(option); - } - } - this._cctvSelect.disabled = !enabled || cameras.length === 0; - if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { - this._cctvSelect.value = activeId; - } else if (!activeId) { - this._cctvSelect.selectedIndex = -1; + if (this._cctvSelect) { + const shouldRebuild = + this._cctvSelect.options.length !== cameras.length || + cameras.some( + (cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id, + ); + if (shouldRebuild) { + this._cctvSelect.innerHTML = ''; + for (const camera of cameras) { + const option = document.createElement('option'); + option.value = camera.id; + option.textContent = `${camera.city} · ${camera.name}`; + this._cctvSelect.appendChild(option); } } - - for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { - if (!btn) continue; - btn.disabled = !enabled || cameras.length === 0; - } - if (this._cctvFocusBtn) { - this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; + this._cctvSelect.disabled = !enabled || cameras.length === 0; + if ( + activeId && + Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId) + ) { + this._cctvSelect.value = activeId; + } else if (!activeId) { + this._cctvSelect.selectedIndex = -1; } + } - if (this._cctvCoverageBtn) { - // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed - // (color-coded volumes). The click handler cycles; this renders. - const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); - this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); - this._cctvCoverageBtn.textContent = mode === 'viewshed' + for (const btn of [ + this._cctvNearestBtn, + this._cctvPrevBtn, + this._cctvNextBtn, + ]) { + if (!btn) continue; + btn.disabled = !enabled || cameras.length === 0; + } + if (this._cctvFocusBtn) { + this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; + } + + if (this._cctvCoverageBtn) { + // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed + // (color-coded volumes). The click handler cycles; this renders. + const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); + this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); + this._cctvCoverageBtn.textContent = + mode === 'viewshed' ? 'VIEWSHED ON' - : mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; - this._cctvCoverageBtn.disabled = !enabled; + : mode === 'on' + ? 'COVERAGE ON' + : 'COVERAGE OFF'; + this._cctvCoverageBtn.disabled = !enabled; + } + + if (this._cctvAutoHopBtn) { + const autoHop = !!state?.autoHop; + this._cctvAutoHopBtn.classList.toggle('active', autoHop); + this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; + this._cctvAutoHopBtn.disabled = !enabled; + } + + if (this._cctvProjectionBtn) { + const showProjection = state?.showProjection !== false; + this._cctvProjectionBtn.classList.toggle('active', showProjection); + this._cctvProjectionBtn.textContent = showProjection + ? 'PROJECTION ON' + : 'PROJECTION OFF'; + this._cctvProjectionBtn.disabled = !enabled; + } + + if (this._cctvQualityChip) { + // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, + // no in-world tint): three states driven by cctv.js's deriveCalBadge, + // no client-side scoring math. Casing is unified via _calBadgeLabel so + // the chip and the meta line never drift onto different conventions. + // Save-gated persistence (viewshed design §3e): unsaved live edits show + // EDITED on top of whatever the persisted badge state is — SAVE CAL + // promotes to CALIBRATED, RESET CAL clears. + const badge = activeCamera?.calBadge || null; + const dirty = !!activeCamera?.calDirty; + this._cctvQualityChip.textContent = dirty + ? 'CAL · EDITED (UNSAVED)' + : `CAL · ${this._calBadgeLabel(badge)}`; + this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || ''; + } + + this._syncCctvCalReadout(enabled, activeCamera); + + if (this._cctvMeta) { + if (activeCamera) { + const provider = + activeCamera.sourceLabel || + activeCamera.provider || + 'Configured Source'; + const statusMsg = activeCamera.sourceMessage + ? ` · ${activeCamera.sourceMessage}` + : ''; + const calBadge = activeCamera.calBadge + ? this._calBadgeLabel(activeCamera.calBadge) + : ''; + const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; + this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; + } else if (cameras.length > 0) { + this._cctvMeta.textContent = enabled + ? `${cameras.length} cameras loaded · click a camera to activate` + : `${cameras.length} cameras loaded · enable CCTV to activate`; + } else { + this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; } + } - if (this._cctvAutoHopBtn) { - const autoHop = !!state?.autoHop; - this._cctvAutoHopBtn.classList.toggle('active', autoHop); - this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; - this._cctvAutoHopBtn.disabled = !enabled; + if (this._cctvFrame) { + const nextSrc = enabled ? activeCamera?.frameUrl : null; + const nextCameraId = enabled ? activeCamera?.id || '' : ''; + const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; + const frameLoading = this._cctvFrame.dataset.loading === 'true'; + // A same-camera refresh waits for the current image to settle. Replacing + // src every 10 seconds can cancel a slow but healthy decode forever and + // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are + // immediate so navigation never waits on the prior camera's request. + if ( + nextSrc && + (cameraChanged || + (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc)) + ) { + this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); } - - if (this._cctvProjectionBtn) { - const showProjection = state?.showProjection !== false; - this._cctvProjectionBtn.classList.toggle('active', showProjection); - this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; - this._cctvProjectionBtn.disabled = !enabled; + if (!nextSrc) { + this._clearCctvFrame(); } + } - if (this._cctvQualityChip) { - // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, - // no in-world tint): three states driven by cctv.js's deriveCalBadge, - // no client-side scoring math. Casing is unified via _calBadgeLabel so - // the chip and the meta line never drift onto different conventions. - // Save-gated persistence (viewshed design §3e): unsaved live edits show - // EDITED on top of whatever the persisted badge state is — SAVE CAL - // promotes to CALIBRATED, RESET CAL clears. - const badge = activeCamera?.calBadge || null; - const dirty = !!activeCamera?.calDirty; - this._cctvQualityChip.textContent = dirty - ? 'CAL · EDITED (UNSAVED)' - : `CAL · ${this._calBadgeLabel(badge)}`; - this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); - } - - this._syncCctvCalReadout(enabled, activeCamera); - - if (this._cctvMeta) { - if (activeCamera) { - const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; - const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; - const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; - const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; - this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; - } else if (cameras.length > 0) { - this._cctvMeta.textContent = enabled - ? `${cameras.length} cameras loaded · click a camera to activate` - : `${cameras.length} cameras loaded · enable CCTV to activate`; - } else { - this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; - } - } - - if (this._cctvFrame) { - const nextSrc = enabled ? activeCamera?.frameUrl : null; - const nextCameraId = enabled ? (activeCamera?.id || '') : ''; - const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; - const frameLoading = this._cctvFrame.dataset.loading === 'true'; - // A same-camera refresh waits for the current image to settle. Replacing - // src every 10 seconds can cancel a slow but healthy decode forever and - // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are - // immediate so navigation never waits on the prior camera's request. - if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { - this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); - } - if (!nextSrc) { - this._clearCctvFrame(); - } - } - - this._syncCctvSourceBadge(activeCamera, enabled); - this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); + this._syncCctvSourceBadge(activeCamera, enabled); + this._typeCctvSummary( + state?.summary || + 'Enable CCTV to start camera-linked intelligence summaries.', + ); } export function _typeCctvSummary(text) { - if (this.destroyed || !this._cctvSummary) return; - const nextText = String(text || '').trim() || 'No summary available.'; - if (nextText === this._lastCctvSummaryText) return; - this._lastCctvSummaryText = nextText; + if (this.destroyed || !this._cctvSummary) return; + const nextText = String(text || '').trim() || 'No summary available.'; + if (nextText === this._lastCctvSummaryText) return; + this._lastCctvSummaryText = nextText; - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummary.textContent = ''; - let idx = 0; - this._cctvSummaryTypingTimer = setInterval(() => { - if (this.destroyed) return; - idx += 3; - if (idx >= nextText.length) { - this._cctvSummary.textContent = nextText; - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummaryTypingTimer = null; - return; - } - this._cctvSummary.textContent = nextText.slice(0, idx); - }, 20); + clearInterval(this._cctvSummaryTypingTimer); + this._cctvSummary.textContent = ''; + let idx = 0; + this._cctvSummaryTypingTimer = setInterval(() => { + if (this.destroyed) return; + idx += 3; + if (idx >= nextText.length) { + this._cctvSummary.textContent = nextText; + clearInterval(this._cctvSummaryTypingTimer); + this._cctvSummaryTypingTimer = null; + return; + } + this._cctvSummary.textContent = nextText.slice(0, idx); + }, 20); } export function _updateCctvSyncChip(loading, enabled) { - if (this.destroyed) return; - if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; - const total = Number(loading?.total) || 0; - const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); - const busy = !!enabled && !!loading?.active && total > 0; + if (this.destroyed) return; + if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) + return; + const total = Number(loading?.total) || 0; + const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); + const busy = !!enabled && !!loading?.active && total > 0; - if (busy) { - clearTimeout(this._cctvChipHideTimer); + if (busy) { + clearTimeout(this._cctvChipHideTimer); + this._cctvChipHideTimer = null; + this._cctvChipWasBusy = true; + this.actions.setSplitFlapText(this._cctvSyncLabel, 'loading frames'); + // The counter is left plain on purpose: it ticks every few frames + // during a grid load, and flapping it would read as a slot machine. + this._cctvSyncProgress.textContent = `${loaded}/${total}`; + this._cctvSyncChip.classList.add('visible'); + return; + } + + if (this._cctvChipWasBusy && enabled && total > 0) { + // Load just completed — flash the final count, then auto-hide. + this._cctvChipWasBusy = false; + this.actions.setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); + this._cctvSyncProgress.textContent = `${total}/${total}`; + this._cctvSyncChip.classList.add('visible'); + clearTimeout(this._cctvChipHideTimer); + this._cctvChipHideTimer = window.setTimeout(() => { + if (this.destroyed) return; this._cctvChipHideTimer = null; - this._cctvChipWasBusy = true; - this.actions.setSplitFlapText(this._cctvSyncLabel, 'loading frames'); - // The counter is left plain on purpose: it ticks every few frames - // during a grid load, and flapping it would read as a slot machine. - this._cctvSyncProgress.textContent = `${loaded}/${total}`; - this._cctvSyncChip.classList.add('visible'); - return; - } - - if (this._cctvChipWasBusy && enabled && total > 0) { - // Load just completed — flash the final count, then auto-hide. - this._cctvChipWasBusy = false; - this.actions.setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); - this._cctvSyncProgress.textContent = `${total}/${total}`; - this._cctvSyncChip.classList.add('visible'); - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = window.setTimeout(() => { - if (this.destroyed) return; - this._cctvChipHideTimer = null; - this._cctvSyncChip.classList.remove('visible'); - }, 1500); - return; - } - - if (!this._cctvChipHideTimer) { - this._cctvChipWasBusy = false; this._cctvSyncChip.classList.remove('visible'); - } + }, 1500); + return; + } + + if (!this._cctvChipHideTimer) { + this._cctvChipWasBusy = false; + this._cctvSyncChip.classList.remove('visible'); + } } From 4298fb0442e9d51ee2638cbd5e5c491cc8507783 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 00:43:08 -0700 Subject: [PATCH 34/92] test: stabilize Radio HUD measurements and satellite fixtures --- scripts/qa-radio.mjs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index a232bfdeb..1d4970d9f 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2,7 +2,8 @@ /** * Deterministic browser proof for the Radio companion layer. * - * Intercepts only `/api/radio/*` with a 750-station fixture and stubs the + * Intercepts Radio endpoints with a 750-station fixture, supplies fixed + * satellite/context support responses, and stubs the * browser media `play()` primitive. It proves marker scale/culling, dynamic * station-tag filtering, first-click selection, direct-action-only playback, panel/Context * independence, restoration without autoplay, responsive UI, and a clean @@ -165,6 +166,28 @@ async function main() { let catalogResponseDelayMs = 0; page.on('request', (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 === APP_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; + } if (url.origin === APP_ORIGIN && url.pathname === '/api/radio/stations') { const response = { status: 200, @@ -2006,6 +2029,10 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 320)); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }; + const waitForHudSettle = async () => { + await new Promise((resolve) => setTimeout(resolve, 560)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; const prior = { cockpit: document.body.classList.contains('cockpit-mode'), cockpitActive: manager.cockpitView.active, @@ -2055,7 +2082,7 @@ async function main() { manager._setHudVariant('tactical'); manager.hud.setMode('on'); manager._updateHudButtonState(); - await waitForLayout(); + await waitForHudSettle(); const layoutSteps = []; // The two Cockpit lanes are solved independently: the accordion against @@ -2110,10 +2137,6 @@ async function main() { }; // The Intel HUD fades over 400ms and keeps its readout rects for the // whole transition, so both lanes are measured only once it has settled. - const waitForHudSettle = async () => { - await new Promise((resolve) => setTimeout(resolve, 560)); - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - }; for (let index = 0; index < 5; index += 1) { recordLayoutStep(); if (index < 4) { @@ -2280,6 +2303,7 @@ async function main() { } result = { cockpitPanelInteraction, + layoutSteps, displayOpened, sharedDisplayControlsPortaled, radioOpened, From c6ac74c242fc86a4e9e985ff8e9c9cd473949d33 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 00:45:35 -0700 Subject: [PATCH 35/92] test: follow the camera panel presentation owner --- src/data/cctv.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index 2d5464430..ca52a3305 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -67,8 +67,8 @@ import { activateCctvCameraFromWorldClick, } from '../cctvFocusRequest.js'; -const UI_SOURCE = fs.readFileSync( - path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui.js'), +const CCTV_PRESENTATION_SOURCE = fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui', 'cctvPresentation.js'), 'utf8', ); @@ -931,10 +931,10 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones assert.equal(cctvCycleIndex(2, 1, records.length), 0); assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1); - const renderer = UI_SOURCE.match(/_renderCctvState\(state\) \{[\s\S]*?\n \}\n/); + const renderer = CCTV_PRESENTATION_SOURCE.match(/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/); assert.ok(renderer, '_renderCctvState is missing'); assert.match(renderer[0], /else if \(!activeId\)[\s\S]*?selectedIndex = -1/); - assert.match(renderer[0], /_cctvFocusBtn\.disabled = !enabled \|\| cameras\.length === 0 \|\| !activeId/); + assert.match(renderer[0], /_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/); } finally { cctvLayer.setParams({ autoHop: false }); _setCctvOverlayHostForTest(); From e1c3045203eb0492ca2b3c7e981250c0f40dbcce Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 01:16:53 -0700 Subject: [PATCH 36/92] test: exercise camera controls and stale frame handling in the browser --- scripts/format-scope.json | 3 +- scripts/qa-camera-controls.mjs | 397 +++++++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-camera-controls.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c523193b8..d46a8d9bb 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -180,5 +180,6 @@ "src/ui/cctvControls.js", "src/ui/cctvControls.test.mjs", "src/ui/cctvFrames.js", - "src/ui/cctvPresentation.js" + "src/ui/cctvPresentation.js", + "scripts/qa-camera-controls.mjs" ] diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs new file mode 100644 index 000000000..78ec1650e --- /dev/null +++ b/scripts/qa-camera-controls.mjs @@ -0,0 +1,397 @@ +#!/usr/bin/env node +/** Camera-panel browser acceptance with controlled sources, images and terrain. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; +const origin = process.env.QA_BASE_URL || 'http://localhost:4173'; +const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control'; +const sources = [ + { + id: 'qa-camera-a', + name: 'QA Camera A', + lat: 30.2747, + lon: -97.7403, + headingDeg: 30, + }, + { + id: 'qa-camera-b', + name: 'QA Camera B', + lat: 30.2672, + lon: -97.7431, + headingDeg: 200, + }, +].map((camera) => ({ + ...camera, + city: 'Austin', + cityId: 'austin', + provider: 'QA camera fixture', + feedType: 'snapshot', + feedConfigured: true, + pitchDeg: -18, + fovDeg: 60, + rangeM: 300, + mountHeightM: 8, +})); +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, ok, detail) => { + console.log( + `[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`, + ); + if (!ok) failures++; +}; +let delayCameraA = false; +const delayedFrames = new Set(); +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.setRequestInterception(true); + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.origin !== new URL(origin).origin) return void request.continue(); + const json = (body) => + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + if (url.pathname === '/api/cctv/sources') return void json({ sources }); + if (url.pathname === '/api/cctv/health') + return void json({ + cameras: sources.map((camera) => ({ + id: camera.id, + status: 'ok', + sourceKind: 'snapshot', + label: 'QA camera fixture', + })), + }); + if (url.pathname.startsWith('/api/cctv/frame/')) { + const cameraA = url.pathname.endsWith('qa-camera-a'); + const respond = () => + request + .respond({ + status: 200, + contentType: 'image/svg+xml', + headers: { 'Cache-Control': 'no-store' }, + body: `QA CAMERA ${cameraA ? 'A' : 'B'}`, + }) + .catch(() => {}); + if (cameraA && delayCameraA) delayedFrames.add(respond); + else void respond(); + return; + } + if (url.pathname === '/api/terrain/heights') { + const points = (url.searchParams.get('points') || '') + .split(';') + .filter(Boolean); + return void json({ + results: points.map((point) => { + const [lon, lat] = point.split(',').map(Number); + return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 }; + }), + }); + } + if (url.pathname === '/api/openai/hud-summary') + return void json({ summary: 'QA camera controls' }); + void request.continue(); + }); + await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._cctvControls && + window.__godsEyeView?.dataManager?.layers?.has('cctv') && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.$eval('[data-collapse-target="cctv-panel"]', (button) => + button.click(), + ); + await page.click('#cctv-enable-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .cameras.length === 2 && + !document.getElementById('cctv-camera-select').disabled, + { timeout: 30000 }, + ); + check( + 'CCTV enable uses the supplied two-camera catalog', + await page.$eval( + '#cctv-camera-select', + (select) => select.options.length === 2 && !select.disabled, + ), + ); + await page.select('#cctv-camera-select', 'qa-camera-a'); + await page.waitForFunction( + () => + document.getElementById('cctv-frame').dataset.cameraId === + 'qa-camera-a' && + document + .getElementById('cctv-frame-wrap') + .classList.contains('has-frame'), + ); + const state = () => + page.evaluate(() => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(), + ); + check( + 'selected camera, decoded preview and source label agree', + await page.evaluate(() => { + const camera = window.__godsEyeView.dataManager.layers + .get('cctv') + .module.getUIState().activeCamera; + return ( + camera.id === 'qa-camera-a' && + document.getElementById('cctv-frame').src.includes('qa-camera-a') && + document + .getElementById('cctv-meta') + .textContent.includes('QA camera fixture') + ); + }), + ); + const coverage = (await state()).coverageMode; + const modes = []; + for (let i = 0; i < 3; i++) { + await page.click('#cctv-coverage-btn'); + modes.push((await state()).coverageMode); + } + check( + 'coverage cycles through all three modes and returns to its starting state', + new Set(modes).size === 3 && modes.at(-1) === coverage, + modes, + ); + const projection = (await state()).showProjection; + await page.click('#cctv-projection-btn'); + check( + 'projection control updates the layer state', + (await state()).showProjection === !projection, + ); + await page.click('#cctv-projection-btn'); + await page.click('#cctv-next-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .activeCameraId === 'qa-camera-b', + ); + await page.click('#cctv-prev-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .activeCameraId === 'qa-camera-a', + ); + check( + 'native Next and Previous return to the original camera', + (await state()).activeCameraId === 'qa-camera-a', + ); + for (const [selector, key] of [ + ['#cctv-auto-hop-btn', 'autoHop'], + ['#cctv-adjust-btn', 'calibrationMode'], + ]) { + const before = (await state())[key]; + await page.click(selector); + const changed = (await state())[key] === !before; + await page.click(selector); + check( + `${key} control changes and restores the layer setting`, + changed && (await state())[key] === before, + ); + } + // Auto Hop can legitimately advance immediately; restore the calibration target. + await page.select('#cctv-camera-select', 'qa-camera-a'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .activeCameraId === 'qa-camera-a', + ); + const heading = (await state()).activeCamera.headingDeg; + const chip = '.cctv-cal-value[data-cal-field="heading"]'; + await page.click(chip); + await page.focus(`${chip} input`); + await page.keyboard.down(selectAllModifier); + await page.keyboard.press('a'); + await page.keyboard.up(selectAllModifier); + await page.keyboard.type(String(heading + 10)); + await page.keyboard.press('Enter'); + check( + 'native calibration editing commits an offset for the selected camera', + Math.abs((await state()).activeCamera.headingDeg - (heading + 10)) < 0.01, + ); + await page.click('#cctv-calib-save-btn'); + check( + 'Save persists the selected camera calibration', + await page.evaluate(() => + Boolean( + JSON.parse( + localStorage.getItem('godsEyeView.cctv.calibration.v2') || '{}', + )['qa-camera-a'], + ), + ), + ); + await page.click('#cctv-calib-reset-btn'); + check( + 'Reset restores the original pose', + Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01, + ); + await page.click(chip); + await page.focus(`${chip} input`); + await page.keyboard.down(selectAllModifier); + await page.keyboard.press('a'); + await page.keyboard.up(selectAllModifier); + await page.keyboard.type('110'); + await page.keyboard.press('Escape'); + check( + 'Escape cancels editing without collapsing CCTV', + (await page.evaluate( + () => + !document.querySelector('.cctv-cal-input') && + !document.getElementById('cctv-panel').classList.contains('collapsed'), + )) && Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01, + ); + await page.click(chip); + await page.evaluate(() => { + const input = document.querySelector('.cctv-cal-input'); + input.value = '110'; + window.__godsEyeView.dataManager.layers + .get('cctv') + .module.selectCamera('qa-camera-b'); + input.dispatchEvent(new Event('blur')); + }); + check( + 'an external camera selection cancels the prior calibration editor', + (await state()).activeCamera.id === 'qa-camera-b' && + Math.abs((await state()).activeCamera.headingDeg - 200) < 0.01 && + (await page.$('.cctv-cal-input')) === null, + ); + await page.waitForFunction( + () => + document.getElementById('cctv-frame').dataset.cameraId === + 'qa-camera-b' && + document + .getElementById('cctv-frame-wrap') + .classList.contains('has-frame'), + ); + await page.setCacheEnabled(false); + delayCameraA = true; + await page.select('#cctv-camera-select', 'qa-camera-a'); + await page.waitForFunction( + () => + document.getElementById('cctv-frame').dataset.cameraId === + 'qa-camera-a' && + document.getElementById('cctv-frame').dataset.loading === 'true', + ); + await page.evaluate(() => { + const image = + window.__godsEyeView.styleManager._cctvControls._cctvFramePreloader; + window.__qaLateCctvImage = image; + window.__qaLateCctvImageLoaded = false; + image.addEventListener( + 'load', + () => { + window.__qaLateCctvImageLoaded = true; + }, + { once: true }, + ); + }); + check( + 'a newly selected camera clears old pixels while its frame loads', + await page.$eval( + '#cctv-frame-wrap', + (element) => !element.classList.contains('has-frame'), + ), + ); + await page.select('#cctv-camera-select', 'qa-camera-b'); + await page.waitForFunction( + () => + document.getElementById('cctv-frame').dataset.cameraId === + 'qa-camera-b' && + document + .getElementById('cctv-frame-wrap') + .classList.contains('has-frame'), + ); + delayCameraA = false; + await Promise.all([...delayedFrames].map((respond) => respond())); + delayedFrames.clear(); + await page.waitForFunction(() => window.__qaLateCctvImageLoaded); + check( + 'late frames cannot overwrite the latest camera selection', + await page.$eval( + '#cctv-frame', + (image) => + image.dataset.cameraId === 'qa-camera-b' && + image.src.includes('qa-camera-b'), + ), + ); + delayCameraA = false; + await page.click('#cctv-enable-btn'); + await page.waitForFunction( + () => !window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const disabledCleanly = await page.$eval( + '#cctv-frame', + (image) => !image.getAttribute('src') && image.dataset.cameraId === '', + ); + await page.click('#cctv-enable-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.isEnabled('cctv') && + document + .getElementById('cctv-frame-wrap') + .classList.contains('has-frame'), + ); + check( + 'disable clears the preview and re-enable reacquires the camera frame', + disabledCleanly, + ); + await page.$eval('#cctv-frame', (image) => + image.scrollIntoView({ block: 'nearest' }), + ); + fs.mkdirSync('qa-shots/camera-controls', { recursive: true }); + await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' }); + await page.evaluate(() => { + const camera = window.__godsEyeView.viewer.camera; + camera.setView({ + orientation: { + heading: camera.heading + Math.PI / 3, + pitch: -0.6, + roll: 0, + }, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 700)); + await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' }); + await page.setViewport({ width: 390, height: 844 }); + await new Promise((resolve) => setTimeout(resolve, 700)); + await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' }); + check( + 'narrow camera panel remains within the viewport', + await page.$eval('#cctv-panel', (panel) => { + const rect = panel.getBoundingClientRect(); + return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth + 1; + }), + ); + await page.evaluate(() => window.__godsEyeView.styleManager.dispose()); + check( + 'CCTV controller is destroyed with the real UI', + await page.evaluate( + () => window.__godsEyeView.styleManager._cctvControls.destroyed, + ), + ); + check( + 'camera controls produce no uncaught browser errors', + errors.length === 0, + errors, + ); +} finally { + await Promise.all([...delayedFrames].map((respond) => respond())); + await browser.close(); +} +if (failures) process.exitCode = 1; From 1b9ea0e8e3f727fe98aa02ebad38ae1c24ff0ba8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 01:23:55 -0700 Subject: [PATCH 37/92] fix: preserve camera controls when rebuilding the Radio panel --- scripts/qa-camera-controls.mjs | 16 ++++++++++++++++ src/ui.js | 1 - 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index 78ec1650e..7eb2aeca7 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -378,6 +378,22 @@ try { return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth + 1; }), ); + check( + 'rebuilding Radio preserves the active camera controller and subscription', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + const camera = ui._cctvControls; + const cameraId = camera.getState()?.activeCameraId; + ui._initRadioPanel(); + ui._radioControls.connect(); + return ( + ui._cctvControls === camera && + !camera.destroyed && + Boolean(camera._cctvUnsubscribe) && + camera.getState()?.activeCameraId === cameraId + ); + }), + ); await page.evaluate(() => window.__godsEyeView.styleManager.dispose()); check( 'CCTV controller is destroyed with the real UI', diff --git a/src/ui.js b/src/ui.js index fa0637c2a..844bc3781 100644 --- a/src/ui.js +++ b/src/ui.js @@ -4885,7 +4885,6 @@ export class StyleManager { /** Wire the independent Radio companion controls. */ _initRadioPanel() { - this._cctvControls?.destroy(); this._radioControls?.destroy(); this._radioControls = new RadioControls({ elements: { From fc369cc65a1cb98234c32e70b54fdb467237df51 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 01:40:59 -0700 Subject: [PATCH 38/92] refactor: isolate Context controls and layer restoration --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 8 + docs/CURRENT-STATE.md | 10 + package.json | 4 +- scripts/package-boundaries.json | 21 + scripts/qa-cockpit-utility.mjs | 96 +-- scripts/qa-layer-panel.mjs | 4 +- scripts/qa-radio.mjs | 76 +- scripts/qa-ui-disposal.mjs | 10 + src/cameraHandoff.test.mjs | 4 +- src/cockpitMarkup.test.mjs | 20 +- src/contactsDetectionPolicy.test.mjs | 6 +- src/contextSessionOrdering.test.mjs | 100 +-- src/contextTabKeyboard.test.mjs | 66 +- src/sharelink.celestial.test.mjs | 22 +- src/ui.js | 1086 +------------------------- src/ui/context.js | 1 + src/ui/contextActions.js | 219 ++++++ src/ui/contextBindings.js | 77 ++ src/ui/contextControls.js | 74 ++ src/ui/contextControls.test.mjs | 115 +++ src/ui/contextLayerChanges.js | 187 +++++ src/ui/contextPresentation.js | 34 + src/ui/contextSession.js | 162 ++++ src/ui/contextSubscriptions.js | 123 +++ src/ui/contextTransactions.js | 215 +++++ 26 files changed, 1471 insertions(+), 1271 deletions(-) create mode 100644 src/ui/context.js create mode 100644 src/ui/contextActions.js create mode 100644 src/ui/contextBindings.js create mode 100644 src/ui/contextControls.js create mode 100644 src/ui/contextControls.test.mjs create mode 100644 src/ui/contextLayerChanges.js create mode 100644 src/ui/contextPresentation.js create mode 100644 src/ui/contextSession.js create mode 100644 src/ui/contextSubscriptions.js create mode 100644 src/ui/contextTransactions.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9137393db..d3b655b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal. + - Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal. - Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 4e705a627..bea9fd09a 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -302,3 +302,11 @@ subscriptions before ending the active tuning interaction. presentation. It receives DOM elements, the existing camera port and explicit application actions; it imports no provider, layer or camera engine. Selection, placement, navigation and storage policy remain outside the component family. + +### Context coordination + +`ui/context` owns mode controls, transactions, session restoration and manager +subscriptions. Composition supplies the manager, installations search and +explicit visual/panel actions. `ui/context/policy` exposes the existing pure +mode and restoration rules. No source transport or renderer is imported by +these components; initial state and action results retain their existing shape. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fcce42e0e..d2d624a2b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,15 @@ # God's Eye View Current State +## Context coordination + +Context controls own Contacts/Space Missions state, entry and exit transactions, +layer snapshots and restoration. The application supplies the existing manager, +installations search and camera/panel actions. Tab listeners and pending +presentation work stop during disposal; layer restoration retains its existing +compensation and latest-intent rules. Clear Selected Layers shares this owner, +so an older restore cannot replay over a newer Clear action. + + ## Camera panel ownership CCTV controls receive the existing camera port and explicit application actions. diff --git a/package.json b/package.json index 62c51ed5e..4cca89785 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,8 @@ "./ui/layers/feedback": "./src/loadingFeedback.js", "./ui/location": "./src/ui/location.js", "./ui/radio": "./src/ui/radio.js", - "./ui/cctv": "./src/ui/cctv.js" + "./ui/cctv": "./src/ui/cctv.js", + "./ui/context": "./src/ui/context.js", + "./ui/context/policy": "./src/contextModePolicy.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 83d976631..1b46e4339 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -369,5 +369,26 @@ "src/ui/cctvPresentation.js" ], "external": [] + }, + "context-controls": { + "exports": [ + "./ui/context", + "./ui/context/policy" + ], + "modules": [ + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js", + "src/contextModePolicy.js", + "src/rightRailPolicy.js", + "src/ui/panelRailGeometry.js" + ], + "external": [] } } diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 1f67ea7db..48d350472 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -184,21 +184,21 @@ try { const toasts = []; styleManager._showToast = (message) => { toasts.push(String(message)); }; try { - if (styleManager._contextMode) await styleManager._selectContextMode(null); - const contactsResult = await styleManager._selectContextMode('flights'); - const missionResult = await styleManager._selectContextMode('space-missions'); + if (styleManager._contextControls._contextMode) await styleManager._contextControls._selectContextMode(null); + const contactsResult = await styleManager._contextControls._selectContextMode('flights'); + const missionResult = await styleManager._contextControls._selectContextMode('space-missions'); const switched = { contactsResult, missionResult, - mode: styleManager._contextMode, + mode: styleManager._contextControls._contextMode, missionsEnabled: dataManager.isEnabled('rocket-launches'), toasts: [...toasts], }; - const exitResult = await styleManager._selectContextMode(null); + const exitResult = await styleManager._contextControls._selectContextMode(null); return { ...switched, exitResult, - modeAfterExit: styleManager._contextMode, + modeAfterExit: styleManager._contextControls._contextMode, missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'), }; } finally { @@ -281,16 +281,16 @@ try { const deadline = performance.now() + 5_000; while (performance.now() < deadline) { if ( - styleManager._contextModeEntering === null - && styleManager._contextSessionSnapshot === null + styleManager._contextControls._contextModeEntering === null + && styleManager._contextControls._contextSessionSnapshot === null && dataManager.isEnabled(siblingId) && !dataManager.isEffectivelyEnabled('rocket-launches') ) break; await new Promise((resolve) => setTimeout(resolve, 20)); } timedOut = !( - styleManager._contextModeEntering === null - && styleManager._contextSessionSnapshot === null + styleManager._contextControls._contextModeEntering === null + && styleManager._contextControls._contextSessionSnapshot === null && dataManager.isEnabled(siblingId) && !dataManager.isEffectivelyEnabled('rocket-launches') ); @@ -298,8 +298,8 @@ try { exercised: true, transitionResult, timedOut, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingRestored: dataManager.isEnabled(siblingId), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), toasts, @@ -382,7 +382,7 @@ try { rocketEntry.module.disable = async () => true; styleManager._showToast = (message) => { toasts.push(String(message)); }; try { - const entry = styleManager._selectContextMode('space-missions'); + const entry = styleManager._contextControls._selectContextMode('space-missions'); await updateStarted; const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' }); releaseUpdate(); @@ -390,15 +390,15 @@ try { const committed = { entryResult, replacementResult, - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId), missionEnabled: dataManager.isEnabled('rocket-launches'), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), toasts: [...toasts], }; - const exitResult = await styleManager._selectContextMode(null); + const exitResult = await styleManager._contextControls._selectContextMode(null); return { exercised: true, ...committed, @@ -482,7 +482,7 @@ try { styleManager._showToast = (message) => { toasts.push(String(message)); }; try { const entry = styleManager._runUserFacingContextAction( - (notificationToken) => styleManager._selectContextMode( + (notificationToken) => styleManager._contextControls._selectContextMode( 'space-missions', { notificationToken }, ), @@ -498,9 +498,9 @@ try { exercised: true, entryResult, offResult, - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingRestored: dataManager.isEnabled(siblingId), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), toasts, @@ -567,35 +567,35 @@ try { await new Promise((resolve) => setTimeout(resolve, 80)); const voiceState = { voiceOn, - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId), }; - const snapshotBeforeOff = [...(styleManager._contextSessionSnapshot?.enabledLayerIds || [])]; + const snapshotBeforeOff = [...(styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds || [])]; const voiceOff = await dataManager.setEnabled('rocket-launches', false, { origin: 'voice' }); - const reactionCountAfterOff = styleManager._contextLayerReactionPromises.size; + const reactionCountAfterOff = styleManager._contextControls._contextLayerReactionPromises.size; await styleManager._waitForContextLayerSettlement(); const restoredSiblingEntry = dataManager.layers.get(siblingId); const voiceExit = { voiceOff, snapshotBeforeOff, reactionCountAfterOff, - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingRestored: dataManager.isEffectivelyEnabled(siblingId), siblingEnabled: restoredSiblingEntry?.enabled, siblingLifecycle: restoredSiblingEntry?.lifecycleState, - restoreActive: Boolean(styleManager._contextRestoreState), + restoreActive: Boolean(styleManager._contextControls._contextRestoreState), }; await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' }); await new Promise((resolve) => setTimeout(resolve, 40)); const internalState = { - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'), }; await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' }); @@ -707,7 +707,7 @@ try { const blockerEntry = dataManager.layers.get(blockerId); blockerEntry.initialized = true; await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' }); - const transition = styleManager._selectContextMode('flights'); + const transition = styleManager._contextControls._selectContextMode('flights'); const disableStart = await settleWithin(disableStarted, gateTimeoutMs); if (!disableStart.settled) { releaseDisable(); @@ -725,7 +725,7 @@ try { styleManager.cockpitView.syncEntry(); const entry = document.getElementById('cockpit-entry'); const pending = { - changing: styleManager._contextModeChanging, + changing: styleManager._contextControls._contextModeChanging, entryHidden: Boolean(entry?.hidden), enterResult: styleManager.cockpitView.enter(), trackerPreserved: viewer.trackedEntity === trackedEntityBefore, @@ -763,7 +763,7 @@ try { // probe must not poison the checks that run after it. releaseDisable(); if (!deferCleanup && dataManager.layers.has(blockerId)) { - if (styleManager._contextSessionSnapshot) await styleManager._selectContextMode(null); + if (styleManager._contextControls._contextSessionSnapshot) await styleManager._contextControls._selectContextMode(null); await window.__gevQaUnregisterLayer(dataManager, blockerId); styleManager.cockpitView.syncEntry(); } @@ -872,7 +872,7 @@ try { let requestSeen = false; let transition = null; try { - await styleManager._selectContextMode(null); + await styleManager._contextControls._selectContextMode(null); await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' }); // Hold the first Overpass request open, honouring the module's own @@ -913,7 +913,7 @@ try { }); }; - transition = styleManager._selectContextMode('flights'); + transition = styleManager._contextControls._selectContextMode('flights'); // The activation promise stays PENDING while the first fetch is out, and // the Contacts panel comes up behind it — that is what a deferred // dependency is for. Verified live on :4272: the panel renders and reads @@ -984,8 +984,8 @@ try { released = true; window.fetch = realFetch; if (transition) await settleWithin(transition, 20_000); - if (styleManager._contextMode !== 'flights') { - await settleWithin(styleManager._selectContextMode('flights'), 15_000); + if (styleManager._contextControls._contextMode !== 'flights') { + await settleWithin(styleManager._contextControls._selectContextMode('flights'), 15_000); } } }); @@ -1060,7 +1060,7 @@ try { return { before: before ? { layerId: before.layerId, id: before.id } : null, after: after ? { layerId: after.layerId, id: after.id } : null, - mode: styleManager._contextMode, + mode: styleManager._contextControls._contextMode, released, refocused, trackedId: trackedInfo?.icao24 || trackedInfo?.id || null, @@ -1206,9 +1206,9 @@ try { // Start from outside both, so activation is a real edge. cockpit.exit({ restoreTracking: true }); await settle(); - await styleManager._selectContextMode(null); + await styleManager._contextControls._selectContextMode(null); await settle(400); - const cleanupSnapshotCleared = styleManager._contextSessionSnapshot === null; + const cleanupSnapshotCleared = styleManager._contextControls._contextSessionSnapshot === null; const cleanupBlockerId = '__qa_slow_contacts_sibling__'; const cleanupUnregistered = await window.__gevQaUnregisterLayer( window.__godsEyeView.dataManager, @@ -1224,7 +1224,7 @@ try { const overriddenBeforeContacts = styleManager._detectionUserOverridden; // 1. Activating Contacts forces the tactical preset on. - const contactsOn = await styleManager._selectContextMode('flights'); + const contactsOn = await styleManager._contextControls._selectContextMode('flights'); await settle(400); const afterContactsOn = mode(); const afterContactsOnDensity = density(); @@ -1254,7 +1254,7 @@ try { await settle(); // 4. Deactivating Contacts restores the pre-Contacts state. - await styleManager._selectContextMode(null); + await styleManager._contextControls._selectContextMode(null); await settle(400); const afterContactsOff = mode(); const afterContactsOffDensity = density(); @@ -1268,7 +1268,7 @@ try { await settle(); // Leave the session exactly as the following checks expect it. - const restoredContacts = await styleManager._selectContextMode('flights'); + const restoredContacts = await styleManager._contextControls._selectContextMode('flights'); await settle(400); const restoredCockpit = await enterCockpit(); styleManager._detectionUserOverridden = originalOverridden; @@ -1348,8 +1348,8 @@ try { step, cockpitActive: cockpit.active, bodyCockpit: document.body.classList.contains('cockpit-mode'), - contextMode: styleManager._contextMode, - contextChanging: styleManager._contextModeChanging, + contextMode: styleManager._contextControls._contextMode, + contextChanging: styleManager._contextControls._contextModeChanging, subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null, tracked: tracker?.gevTrackedId || null, density: styleManager.getDetectionState().densityPct, diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs index 189de40bc..453009929 100644 --- a/scripts/qa-layer-panel.mjs +++ b/scripts/qa-layer-panel.mjs @@ -87,9 +87,9 @@ try { result.push([ 'native clear activation presents busy state', ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' && - !!ui._clearSelectedLayersPromise, + !!ui._contextControls._clearSelectedLayersPromise, ]); - await ui._clearSelectedLayersPromise; + await ui._contextControls._clearSelectedLayersPromise; result.push([ 'clear settles through the existing layer transaction', !manager.isEnabled(id) && diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 1d4970d9f..4c9edcd86 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -783,10 +783,10 @@ async function main() { const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds; const originalIsEnabled = dataManager.isEnabled; const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled; - const originalClear = styleManager._clearLayersOutsideContextMode; - const originalCapture = styleManager._captureContextSessionSnapshot; - const originalContextMode = styleManager._contextMode; - const originalSnapshot = styleManager._contextSessionSnapshot; + const originalClear = styleManager._contextControls._clearLayersOutsideContextMode; + const originalCapture = styleManager._contextControls._captureContextSessionSnapshot; + const originalContextMode = styleManager._contextControls._contextMode; + const originalSnapshot = styleManager._contextControls._contextSessionSnapshot; const originalShowToast = styleManager._showToast; const installations = dataManager.layers.get('military-installations')?.module; const originalSearchNearby = installations?.searchNearby; @@ -797,12 +797,12 @@ async function main() { const semanticFalseMessage = document.getElementById('toast').textContent; const enabled = new Set(); - styleManager._captureContextSessionSnapshot = () => {}; + styleManager._contextControls._captureContextSessionSnapshot = () => {}; const runContextFailure = async (mode, phase, outcome) => { enabled.clear(); enabled.add('local-datacenters'); - styleManager._contextMode = null; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = null; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set(enabled), userAdded: new Set(), userRemoved: new Set(), @@ -822,7 +822,7 @@ async function main() { return true; }; const result = await styleManager._runUserFacingContextAction( - () => styleManager._selectContextMode(mode), + () => styleManager._contextControls._selectContextMode(mode), `${mode} QA transition failed`, ); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -831,8 +831,8 @@ async function main() { phase, outcome, result, - visibleMode: styleManager._contextMode, - snapshotCleared: styleManager._contextSessionSnapshot === null, + visibleMode: styleManager._contextControls._contextMode, + snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null, priorLayerRestored: enabled.has('local-datacenters'), entryEnabled: enabled.has(entryLayerId), flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled, @@ -870,8 +870,8 @@ async function main() { for (const outcome of ['false', 'reject']) { enabled.clear(); enabled.add('local-datacenters'); - styleManager._contextMode = null; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = null; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set(enabled), userAdded: new Set(), userRemoved: new Set(), @@ -886,7 +886,7 @@ async function main() { dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => { if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) { failureUsed = true; - styleManager._handleContextLayerChange({ + styleManager._contextControls._handleContextLayerChange({ type: 'visibility-failed', layerId: candidateId, enabled: shouldEnable, @@ -905,7 +905,7 @@ async function main() { enabled: true, origin: 'user', }); - styleManager._handleContextLayerChange({ + styleManager._contextControls._handleContextLayerChange({ type: 'visibility-blocked', layerId, enabled: true, @@ -925,10 +925,10 @@ async function main() { reason, retryReason, toastMessages, - mode: styleManager._contextMode, - snapshotCleared: styleManager._contextSessionSnapshot === null, + mode: styleManager._contextControls._contextMode, + snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null, priorLayerRestored, - modeChanging: styleManager._contextModeChanging, + modeChanging: styleManager._contextControls._contextModeChanging, }); } } @@ -937,8 +937,8 @@ async function main() { const directActivationFailures = []; for (const layerId of ['military-awareness', 'rocket-launches']) { enabled.clear(); - styleManager._contextMode = null; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = null; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set(['local-datacenters']), userAdded: new Set(), userRemoved: new Set(), @@ -948,7 +948,7 @@ async function main() { shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId); return true; }; - styleManager._handleContextLayerChange({ + styleManager._contextControls._handleContextLayerChange({ type: 'visibility-failed', layerId, enabled: true, @@ -957,8 +957,8 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 50)); directActivationFailures.push({ layerId, - mode: styleManager._contextMode, - snapshotCleared: styleManager._contextSessionSnapshot === null, + mode: styleManager._contextControls._contextMode, + snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null, priorLayerRestored: enabled.has('local-datacenters'), failedLayerDisabled: !enabled.has(layerId), }); @@ -967,8 +967,8 @@ async function main() { const directActivationRollbackFailures = []; for (const layerId of ['military-awareness', 'rocket-launches']) { enabled.clear(); - styleManager._contextMode = null; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = null; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set(['local-datacenters']), userAdded: new Set(), userRemoved: new Set(), @@ -985,7 +985,7 @@ async function main() { shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId); return true; }; - styleManager._handleContextLayerChange({ + styleManager._contextControls._handleContextLayerChange({ type: 'visibility-failed', layerId, enabled: true, @@ -996,7 +996,7 @@ async function main() { directActivationRollbackFailures.push({ layerId, toastMessages, - retryRetained: styleManager._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true, + retryRetained: styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true, }); } @@ -1026,8 +1026,8 @@ async function main() { window.__gevQaRegisterLayer(dataManager, qaLayer); try { const enabledBeforeExit = dataManager.getEnabledLayerIds(); - styleManager._contextMode = 'flights'; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = 'flights'; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set([...enabledBeforeExit, layerId]), userAdded: new Set(), userRemoved: new Set(), @@ -1039,13 +1039,13 @@ async function main() { }; const unhandledBefore = unhandled.length; const result = await styleManager._runUserFacingContextAction( - (notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }), + (notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }), `QA Context exit ${outcome} surfaced once`, ); - const retainedForRetry = styleManager._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true; + const retainedForRetry = styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true; enableOutcome = 'success'; const retryResult = await styleManager._runUserFacingContextAction( - (notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }), + (notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }), `QA Context exit ${outcome} retry failed`, ); contextExitFailures.push({ @@ -1055,9 +1055,9 @@ async function main() { toastMessages, unhandledDelta: unhandled.length - unhandledBefore, retainedForRetry, - mode: styleManager._contextMode, - modeChanging: styleManager._contextModeChanging, - snapshotClearedAfterRetry: styleManager._contextSessionSnapshot === null, + mode: styleManager._contextControls._contextMode, + modeChanging: styleManager._contextControls._contextModeChanging, + snapshotClearedAfterRetry: styleManager._contextControls._contextSessionSnapshot === null, enabledAfterRetry: dataManager.isEnabled(layerId), toastRole: document.getElementById('toast').getAttribute('role'), toastLive: document.getElementById('toast').getAttribute('aria-live'), @@ -1163,10 +1163,10 @@ async function main() { dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds; dataManager.isEnabled = originalIsEnabled; dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled; - styleManager._clearLayersOutsideContextMode = originalClear; - styleManager._captureContextSessionSnapshot = originalCapture; - styleManager._contextMode = originalContextMode; - styleManager._contextSessionSnapshot = originalSnapshot; + styleManager._contextControls._clearLayersOutsideContextMode = originalClear; + styleManager._contextControls._captureContextSessionSnapshot = originalCapture; + styleManager._contextControls._contextMode = originalContextMode; + styleManager._contextControls._contextSessionSnapshot = originalSnapshot; styleManager._showToast = originalShowToast; if (installations) installations.searchNearby = originalSearchNearby; window.removeEventListener('unhandledrejection', onUnhandled); diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 9aabdd75c..e6a8fa3ab 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -58,6 +58,13 @@ try { counts.cctv++; return cctvUnsubscribe(); }; + const contextFields = ['_contextManagerUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe']; + const contextConnected = contextFields.every((field) => typeof ui._contextControls[field] === 'function'); + for (const field of contextFields) { + const unsubscribe = ui._contextControls[field]; + counts[field] = 0; + ui._contextControls[field] = () => { counts[field]++; return unsubscribe?.(); }; + } const resizeHandler = ui._windowResizeHandler; const removeEventListener = window.removeEventListener; counts.resize = 0; @@ -79,6 +86,8 @@ try { resizeHandler && observed.includes('_commandDockTrayObserver'), ), + contextReleased: contextConnected && ui._contextControls.destroyed + && contextFields.every((field) => counts[field] === 1 && ui._contextControls[field] === null), observersReleased: observed.every( (name) => counts[name] === 1 && ui[name] === null, ), @@ -99,6 +108,7 @@ try { 'real UI has the expected live resources before disposal', result.connected, ); + check('UI disposal releases all Context subscriptions and stops its controls', result.contextReleased); check( 'UI disposal disconnects each active panel observer once', result.observersReleased, diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 39b1b27d0..e92ec2a56 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -300,7 +300,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () = 'this._disposed = true;', 'this._stampNavigation();', 'this._removeWorldRequestFocusListener?.();', - 'await this._restoreContextSession();', + 'await this._contextControls.restoreForDisposal();', ], 'dispose invalidation'); }); @@ -311,7 +311,7 @@ test('teardown synchronously closes immediate camera entry points', () => { 'this._removeCctvRequestFocusListener?.();', 'this._removeWorldRequestFocusListener?.();', 'this._navigationOwnerChangedRemover?.();', - 'await this._restoreContextSession();', + 'await this._contextControls.restoreForDisposal();', ], 'synchronous teardown barrier'); const navigation = body( diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index dc3201310..af93a0c65 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,3 +1,7 @@ +import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; +import { clearSelectedLayers } from './ui/contextActions.js'; +import { _selectContextMode } from './ui/contextTransactions.js'; +import { _restoreContextSession } from './ui/contextSession.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); @@ -183,9 +187,7 @@ test('the cockpit reads its aircraft from the layer that owns Cesium tracking', }); test('programmatic Context layer changes cannot bypass explicit expansion policy', () => { - const handler = ui.match(/_handleContextLayerChange\(change\) \{([\s\S]*?)\n \}\n\n _syncContextModeButtons/); - assert.ok(handler, 'Context layer state handler is missing'); - assert.doesNotMatch(handler[1], /setPanelCollapsed\('global-context-panel', false\)/); + assert.doesNotMatch(_handleContextLayerChange.toString(), /setPanelCollapsed\('global-context-panel', false\)/); }); test('share startup isolates panel defaults from recipient-local collapse preferences', () => { @@ -305,13 +307,13 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat manager, /for \(const \{ layerId, intentEpoch \} of targets\)[\s\S]*?visibilityIntentEpoch !== intentEpoch[\s\S]*?await this\.setEnabled\(layerId, false/, ); - assert.match(ui, /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/); - assert.match(ui, /async _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/); - assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/); - assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/); - assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/); + assert.match(clearSelectedLayers.toString(), /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/); + assert.match(_selectContextMode.toString(), /async function _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/); + assert.match(clearSelectedLayers.toString(), /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/); + assert.match(clearSelectedLayers.toString(), /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/); + assert.match(_restoreContextSession.toString(), /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/); assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/); - assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); + assert.match(clearSelectedLayers.toString(), /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); }); test('Cockpit Display portal retains both scroll owners across round trips', () => { diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 98b2401ef..4307cc182 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -1,3 +1,4 @@ +import { _syncContextModeButtons } from './ui/contextPresentation.js'; // Contacts-scoped detection (owner playtest 2026-08-18: "when you click on // Contacts, detections should just turn on, and they should stay on in Cockpit // or in third-person tracking inside Contacts"). @@ -292,10 +293,11 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc 'ACTIVATION turns detection on REGARDLESS of the style-preset override flag', ); assert.match( - uiSource, - /this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\._syncContactsDetection\(\);/, + _syncContextModeButtons.toString(), + /this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\.actions\.syncDetection\(\);/, 'called from _syncContextModeButtons, the funnel every _contextMode mutation routes through', ); + assert.match(uiSource, /syncDetection: \(\) => this\._syncContactsDetection\(\)/); // The cockpit vision hook — the old trigger — must be out of the detection // business entirely, or leaving the cockpit turns detections off again. const visionHook = uiSource.slice( diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 4bc5f6704..a1555c6e7 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -1,11 +1,15 @@ +import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; +import { connectContextManager } from './ui/contextSubscriptions.js'; +import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js'; +import { _selectContextMode, _clearLayersOutsideContextMode } from './ui/contextTransactions.js'; +import { _initGlobalContextPanel } from './ui/contextBindings.js'; +import { setContextMode as contextModeAction } from './ui/contextActions.js'; +import { ContextControls } from './ui/contextControls.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); -// Source-contract pins for StyleManager's Context-session handler ordering. -// ui.js cannot be imported headlessly (it touches the DOM at module scope), so -// these read the shipped source — the idiom established by cockpitMarkup / -// cameraHandoff tests. Each pin guards a bug that shipped or nearly shipped: +// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped: // - session bookkeeping ran AFTER the exit early-return, so the compensating // userAdded.delete never ran on the left-panel chip exit and restoration // resurrected the mission layer the user just disabled; @@ -21,10 +25,7 @@ import { fileURLToPath } from 'node:url'; const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8'); -const handlerStart = src.indexOf('_handleContextLayerChange(change) {'); -assert.ok(handlerStart > 0, 'handler found'); -const handlerEnd = src.indexOf('_syncContextModeButtons() {', handlerStart); -const handler = src.slice(handlerStart, handlerEnd); +const handler = _handleContextLayerChange.toString(); test('context handler: session bookkeeping runs before the exit early-return', () => { const record = handler.indexOf('recordContextSessionUserChange('); @@ -56,9 +57,7 @@ test('cancelled Space Missions entry resolves ownership before settled bookkeepi }); test('direct Context entry captures on the synchronous request boundary', () => { - const attachStart = src.indexOf('attachDataManager(dataManager) {'); - const attachEnd = src.indexOf('_syncContextModeButtons()', attachStart); - const attach = src.slice(attachStart, attachEnd); + const attach = connectContextManager.toString(); assert.match( attach, /subscribeVisibilityRequests\(\(change\) => \{[\s\S]*?shouldCaptureContextSession\(change\)/, @@ -70,9 +69,7 @@ test('direct Context entry captures on the synchronous request boundary', () => }); test('Clear All defers a newer explicit mission guard until its batch settles', () => { - const attachStart = src.indexOf('attachDataManager(dataManager) {'); - const attachEnd = src.indexOf('if (typeof this._dataManager?.subscribeBeforeDestroy', attachStart); - const attach = src.slice(attachStart, attachEnd); + const attach = connectContextManager.toString(); assert.match(attach, /shouldDeferContextEntryDuringClear\(\{/); assert.match(attach, /_contextModeDeferredEntryIntent = \{/); assert.match(attach, /await this\._clearSelectedLayersManagerPromise/); @@ -80,10 +77,7 @@ test('Clear All defers a newer explicit mission guard until its batch settles', }); test('context restore replays explicit companion intent after the stale restore queue drains', () => { - const restore = src.slice( - src.indexOf('async _restoreContextSession('), - src.indexOf('async _selectContextMode('), - ); + const restore = _restoreContextSession.toString(); const restoreAwait = restore.indexOf('await this._dataManager.restoreEnabledLayerIds('); const replay = restore.indexOf('await settleContextIntentReplay('); assert.ok(restoreAwait > 0, 'restore queue is awaited'); @@ -92,10 +86,7 @@ test('context restore replays explicit companion intent after the stale restore }); test('context restore settles the Contacts coordinator before dependency fanout', () => { - const restore = src.slice( - src.indexOf('async _restoreContextSession('), - src.indexOf('async _selectContextMode('), - ); + const restore = _restoreContextSession.toString(); const settle = restore.indexOf("const contactsCoordinatorId = 'military-awareness'"); const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(', settle); const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', settle); @@ -106,10 +97,7 @@ test('context restore settles the Contacts coordinator before dependency fanout' }); test('context restore preserves its exact pending target after a failed transition', () => { - const restore = src.slice( - src.indexOf('async _restoreContextSession('), - src.indexOf('async _selectContextMode('), - ); + const restore = _restoreContextSession.toString(); assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/); assert.match(restore, /if \(restoreError && !this\._contextSessionSnapshot\)/); assert.match(restore, /enabledLayerIds: new Set\(restoreState\.enabledLayerIds\)/); @@ -130,7 +118,7 @@ test('context handler: either failed direct Context-shell start rolls the sessio assert.match(failedBranch, /const failureMessage =/); assert.match( failedBranch, - /await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\._showToast\(failureMessage\);\s*return true;/, + /await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\.showToast\(failureMessage\);\s*return true;/, ); assert.match(failedBranch, /\},\s*failureMessage,\s*\)\);/); assert.match(failedBranch, /_trackContextLayerReaction\(/); @@ -139,10 +127,7 @@ test('context handler: either failed direct Context-shell start rolls the sessio /else if \(!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\)/, ); - const deferredRestore = src.slice( - src.indexOf('async _restoreContextSessionAfterLayerSettles('), - src.indexOf('async _selectContextMode('), - ); + const deferredRestore = _restoreContextSessionAfterLayerSettles.toString(); assert.match(deferredRestore, /await this\._dataManager\?\.waitForLayerSettled\?\.\(layerId\)/); assert.match(deferredRestore, /return this\._restoreContextSession\(\{ notificationToken \}\)/); assert.doesNotMatch(failedBranch, /excludeLayerIds/); @@ -157,14 +142,11 @@ test('wrapped visibility blocks leave the accessible toast to the wrapper token blockedBranch, /!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)/, ); - assert.match(blockedBranch, /this\._showToast\(/); + assert.match(blockedBranch, /this\.showToast\(/); }); test('every user-facing Context exit route settles through the failure surface', () => { - const initPanel = src.slice( - src.indexOf('_initGlobalContextPanel() {'), - src.indexOf('_captureContextSessionSnapshot(', src.indexOf('_initGlobalContextPanel() {')), - ); + const initPanel = _initGlobalContextPanel.toString(); assert.equal((initPanel.match(/void this\._runUserFacingContextAction/g) || []).length, 3); assert.doesNotMatch(initPanel, /falseIsFailure:\s*false/); assert.doesNotMatch(initPanel, /void this\._selectContextMode/); @@ -198,10 +180,7 @@ test('only the expanded Radio Enable gesture requests the contained post-enable }); test('right-rail context entry is transactional: activation result gates the mode', () => { - const select = src.slice( - src.indexOf('async _selectContextMode('), - src.indexOf('async _deactivateContextForLayerChange('), - ); + const select = _selectContextMode.toString(); const activation = select.indexOf('activationIntent = this._dataManager._setEnabledWithIntent('); assert.ok(activation > 0, 'activation result captured'); const failureBlock = select.slice(activation); @@ -220,10 +199,7 @@ test('right-rail context entry is transactional: activation result gates the mod }); test('Context entry awaits isolation and direct shell routes isolate in the visibility guard', () => { - const select = src.slice( - src.indexOf('async _selectContextMode('), - src.indexOf('async _deactivateContextForLayerChange('), - ); + const select = _selectContextMode.toString(); assert.match(select, /await this\._clearLayersOutsideContextMode\(mode, \{ notificationToken, signal \}\)/); assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/); assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/); @@ -255,12 +231,10 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi // invalidates in-flight Context transactions so teardown cannot be raced into // publishing a mode. const dispose = src.slice(src.indexOf(' async dispose() {'), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose() {'))); - assert.match(dispose, /this\._contextModeGeneration \+= 1;/); + assert.match(dispose, /this\._contextControls\.stop\(\)/); + assert.match(ContextControls.prototype.stop.toString(), /this\._contextModeGeneration\+\+/); - const guard = src.slice( - src.indexOf('this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard'), - src.indexOf("if (typeof this._dataManager?.subscribeBeforeDestroy === 'function')"), - ); + const guard = connectContextManager.toString(); assert.match(guard, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/); assert.match(guard, /const notificationToken = change\.notificationToken \|\| Symbol\('direct-context-shell-entry'\)/); assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); @@ -277,10 +251,7 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () => // a bare "did not complete" while the operator's Context was silently gone. // Text and state are derived from the same verdict so they cannot disagree, // and the failed layer ids survive (Manjunath's honesty requirement). - const setter = src.slice( - src.indexOf(' async setContextMode(mode, {'), - src.indexOf(' getCockpitState() {'), - ); + const setter = contextModeAction.toString(); assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost'); assert.match( setter, @@ -301,14 +272,10 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () => }); test('Context cancellation reaches isolation and restore lifecycle mutations', () => { - const clearStart = src.indexOf('async _clearLayersOutsideContextMode('); - const clear = src.slice(clearStart, src.indexOf('_handleContextLayerChange(', clearStart)); + const clear = _clearLayersOutsideContextMode.toString(); assert.match(clear, /setEnabled\(layerId, false, \{[\s\S]*?signal/); - const restore = src.slice( - src.indexOf('async _restoreContextSession('), - src.indexOf('async _restoreContextSessionAfterLayerSettles('), - ); + const restore = _restoreContextSession.toString(); assert.match(restore, /restoreEnabledLayerIds\([\s\S]*?signal/); assert.match(restore, /setEnabled: \(layerId, enabled, options = \{\}\)[\s\S]*?signal/); assert.match(restore, /error\.failedLayerIds = \[contactsCoordinatorId\]/); @@ -319,10 +286,7 @@ test('Context cancellation reaches isolation and restore lifecycle mutations', ( }); test('Context facade preserves success when cancellation arrives after commit', () => { - const setContextMode = src.slice( - src.indexOf('async setContextMode('), - src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')), - ); + const setContextMode = contextModeAction.toString(); assert.match( setContextMode, /transitioned === null \|\| \(!requestIsCurrent\(\) && transitioned !== true\)/, @@ -334,10 +298,7 @@ test('Context facade preserves success when cancellation arrives after commit', }); test('Context production rollback paths merge primary and restore failed-layer identities', () => { - const select = src.slice( - src.indexOf('async _selectContextMode('), - src.indexOf('async _deactivateContextForLayerChange('), - ); + const select = _selectContextMode.toString(); assert.equal( (select.match(/mergeContextTransitionErrors\(transitionError, restoreError\)/g) || []).length, 2, @@ -347,10 +308,7 @@ test('Context production rollback paths merge primary and restore failed-layer i }); test('stale Context cancellation preserves rollback failed-layer identities', () => { - const setContextMode = src.slice( - src.indexOf('async setContextMode('), - src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')), - ); + const setContextMode = contextModeAction.toString(); assert.match( setContextMode, /if \(!requestIsCurrent\(\)\) \{[\s\S]*?\.\.\.cancellationResult\(\),[\s\S]*?failedLayerIds: \[\.\.\.error\.failedLayerIds\]/, diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 092d15fa3..484770e2d 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,3 +1,7 @@ +import { _selectContextMode } from './ui/contextTransactions.js'; +import { _syncContextModeButtons } from './ui/contextPresentation.js'; +import { _initGlobalContextPanel } from './ui/contextBindings.js'; +import { clearSelectedLayers } from './ui/contextActions.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); @@ -18,16 +22,14 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence' assert.match(button[0], /tabindex="0"/); } - const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/); - assert.ok(sync, 'Context mode sync is missing'); - assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/); - assert.match(sync[1], /button\.tabIndex = 0/); - assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/); + const syncSource = _syncContextModeButtons.toString(); + assert.match(syncSource, /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/); + assert.match(syncSource, /button\.tabIndex = 0/); + assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/); }); test('Context transition state preserves focus and Tab availability until settle', () => { - const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/); - assert.ok(sync, 'Context mode sync is missing'); + const syncSource = _syncContextModeButtons.toString(); const attributes = () => new Map(); const makeButton = () => { @@ -56,6 +58,8 @@ test('Context transition state preserves focus and Tab availability until settle const priorDocument = globalThis.document; globalThis.document = { activeElement: missions, getElementById: () => panel }; const owner = { + _globalContextPanel: panel, + actions: { syncDetection() {}, scheduleLayout() {} }, _contextMode: null, _contextModeChanging: true, _globalContextFlightsBtn: contacts, @@ -68,7 +72,7 @@ test('Context transition state preserves focus and Tab availability until settle _scheduleRightPanelLayout() {}, }; try { - Function(sync[1]).call(owner); + _syncContextModeButtons.call(owner); assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions'); for (const button of [contacts, missions]) { assert.equal(button.disabled, false); @@ -79,7 +83,7 @@ test('Context transition state preserves focus and Tab availability until settle owner._contextModeChanging = false; owner._contextMode = 'space-missions'; - Function(sync[1]).call(owner); + _syncContextModeButtons.call(owner); assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions'); for (const button of [contacts, missions]) { assert.equal(button.disabled, false); @@ -93,32 +97,22 @@ test('Context transition state preserves focus and Tab availability until settle }); test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => { - const init = ui.slice( - ui.indexOf('_initGlobalContextPanel() {'), - ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')), - ); - const select = ui.slice( - ui.indexOf('async _selectContextMode('), - ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')), - ); - const clear = ui.slice( - ui.indexOf('clearSelectedLayers() {'), - ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')), - ); - assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2); + const init = _initGlobalContextPanel.toString(); + const select = _selectContextMode.toString(); + const clear = clearSelectedLayers.toString(); + assert.equal((init.match(/if \(this\.destroyed \|\| this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2); assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); }); test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => { - const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/); - assert.ok(init, 'Context panel initialization is missing'); - assert.match(init[1], /event\.key === 'ArrowRight'/); - assert.match(init[1], /event\.key === 'ArrowLeft'/); - assert.match(init[1], /event\.key === 'Home'/); - assert.match(init[1], /event\.key === 'End'/); - assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/); - assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/); + const initSource = _initGlobalContextPanel.toString(); + assert.match(initSource, /event\.key === 'ArrowRight'/); + assert.match(initSource, /event\.key === 'ArrowLeft'/); + assert.match(initSource, /event\.key === 'Home'/); + assert.match(initSource, /event\.key === 'End'/); + assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/); + assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/); }); test('Context tabs draw a visible keyboard-focus outline including active tabs', () => { @@ -136,16 +130,10 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs', }); test('Context async action buttons remain focused while busy', () => { - const init = ui.slice( - ui.indexOf('_initGlobalContextPanel() {'), - ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')), - ); + const init = _initGlobalContextPanel.toString(); const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,')); const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)')); - const clear = ui.slice( - ui.indexOf('clearSelectedLayers() {'), - ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')), - ); + const clear = clearSelectedLayers.toString(); assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/); assert.doesNotMatch(init, /button\.disabled\s*=\s*true/); @@ -155,7 +143,7 @@ test('Context async action buttons remain focused while busy', () => { assert.match(radioSync, new RegExp(`${name}\\.disabled = false`)); } assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/); - assert.match(clear, /_clearLayersControl\?\.setBusy\(true\)/); + assert.match(clear, /this\.setClearBusy\(true\)/); const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8'); assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/); assert.doesNotMatch(control, /button\.disabled\s*=/); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index fd076cbcf..22f29c23b 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -1,3 +1,5 @@ +import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js'; +import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -464,19 +466,17 @@ test('explicit Context transitions claim the visual restore lane before transiti assert.ok(claimIndex < mutationIndex, `${label} must claim before mutation`); }; - const helper = sourceBlock( - ' _claimContextVisualAuthority() {', - ' async _selectContextMode(mode, {', - ); + const helper = _claimContextVisualAuthority.toString(); assert.ok( - helper.includes("claimRestoreLane?.('visual')"), + helper.includes('this.actions.claimVisualAuthority()'), 'the Context authority helper must claim the visual lane', ); - const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction('); + assert.match(uiSource, /claimVisualAuthority: \(\) => this\.shareLinkManager\?\.claimRestoreLane\?\.\('visual'\)/); + const contextPanel = _initGlobalContextPanel.toString(); for (const [start, end, label] of [ - ["this._globalContextFlightsBtn?.addEventListener('click'", "this._globalContextMissionsBtn?.addEventListener('click'", 'Contacts tab'], - ["this._globalContextMissionsBtn?.addEventListener('click'", 'CONTEXT_PANEL_END', 'Space Missions tab'], + ["this.listen(this._globalContextFlightsBtn, 'click'", "this.listen(this._globalContextMissionsBtn, 'click'", 'Contacts tab'], + ["this.listen(this._globalContextMissionsBtn, 'click'", 'CONTEXT_PANEL_END', 'Space Missions tab'], ]) { const startIndex = contextPanel.indexOf(start); const endIndex = end === 'CONTEXT_PANEL_END' @@ -487,7 +487,7 @@ test('explicit Context transitions claim the visual restore lane before transiti } // The voice/tool facade validates the mode first, then transitions. - const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {'); + const facade = setContextMode.toString(); assertContextClaimsBefore(facade, 'this._selectContextMode(', 'setContextMode facade'); // Authority is taken per validated branch, never ahead of validation. The // OFF branch is validated by its own guard; the named-mode branch must claim @@ -517,8 +517,8 @@ test('explicit Context transitions claim the visual restore lane before transiti // detection: `_detectionUserOverridden` is what suppresses the military-style // auto-enable for the rest of the session. Contacts entry is not that. test('Context lane claims never set the session detection-override flag', () => { - const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction('); - const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {'); + const contextPanel = _initGlobalContextPanel.toString(); + const facade = setContextMode.toString(); for (const [block, label] of [ [contextPanel, 'Context panel'], [facade, 'setContextMode facade'], diff --git a/src/ui.js b/src/ui.js index 844bc3781..72e5f8a4c 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,3 +1,4 @@ +import { ContextControls } from './ui/context.js'; import { CctvControls } from './ui/cctv.js'; import { RadioControls } from './ui/radio.js'; import { LocationControls, LocationSearch } from './ui/location.js'; @@ -82,29 +83,8 @@ import { reduceTrafficSyncFeedback, } from './loadingFeedback.js'; import { setSplitFlapText } from './splitFlap.js'; -import { - cockpitEntryAllowed, - contextAllowedLayerIds, - contextLayerEnableBlockReason, - contextRestoreLayerIds, - contextSnapshotLayerIds, - isExplicitUserIntentOrigin, - mergeContextTransitionErrors, - recordContextRestoreExplicitChange, - recordContextSessionUserChange, - runWithContextModeChanging, - settleContextModeChange, - settleContextIntentReplay, - settleUserFacingContextAction, - shouldCaptureContextSession, - shouldDeferContextEntryDuringClear, - shouldExitContextForLayerChange, - spaceMissionEntryCancellationDisposition, - contextModeWord, -} from './contextModePolicy.js'; -import { - shouldExpandGlobalContextPanel, -} from './rightRailPolicy.js'; +import { cockpitEntryAllowed } from './contextModePolicy.js'; + import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout, @@ -350,7 +330,6 @@ const STYLE_STATUS_LABELS = { snow: 'SNOW', }; - /** * Central UI orchestrator for the God's Eye View application. * @@ -2034,8 +2013,6 @@ export class StyleManager { this._shareTrackingAcquiringKey = null; this._shareTrackingNoticeGeneration = 0; this._globeResetPromise = null; - this._clearSelectedLayersPromise = null; - this._clearSelectedLayersManagerPromise = null; this._dataManager = null; this._leftStackLayoutFrame = null; @@ -2173,21 +2150,6 @@ export class StyleManager { this._contextModeStandby = document.getElementById('context-mode-standby'); this._contextFlightsView = document.getElementById('context-flights-view'); this._contextMissionsView = document.getElementById('context-missions-view'); - this._contextMode = null; - this._contextModeChanging = false; - this._contextModeGeneration = 0; - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextModeDeferredEntryIntent = null; - this._contextSessionSnapshot = null; - this._contextRestoreState = null; - this._contextLayerReactionPromises = new Set(); - this._preservePanelStateDuringLayerClear = false; - this._userFacingContextNotificationTokens = new Set(); - this._dataManagerBeforeDestroyUnsubscribe = null; - this._dataManagerVisibilityGuardUnsubscribe = null; - this._dataManagerVisibilityRequestUnsubscribe = null; this._installationsSearchBtn = document.getElementById('installations-search-btn'); this._leftPanelStack = document.getElementById('left-panel-stack'); this._cctvEnableBtn = document.getElementById('cctv-enable-btn'); @@ -2622,7 +2584,6 @@ export class StyleManager { get _bloomStage() { return this._visualEffects.bloomStage; } get _sharpenStage() { return this._visualEffects.sharpenStage; } - /** Advance camera authority and settle any older search UI immediately. */ _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) { this._navigationGeneration += 1; @@ -3617,7 +3578,6 @@ export class StyleManager { this._trafficSyncChip.classList.toggle('visible', presentation.visible); } - /** * Initializes panel collapse buttons and restores persisted collapsed state. * Also sets up hover-expand behavior for the style presets and location bar panels. @@ -3933,18 +3893,6 @@ export class StyleManager { * @returns {void} */ attachDataManager(dataManager) { - if (this._dataManagerBeforeDestroyUnsubscribe) { - this._dataManagerBeforeDestroyUnsubscribe(); - this._dataManagerBeforeDestroyUnsubscribe = null; - } - if (this._dataManagerVisibilityGuardUnsubscribe) { - this._dataManagerVisibilityGuardUnsubscribe(); - this._dataManagerVisibilityGuardUnsubscribe = null; - } - if (this._dataManagerVisibilityRequestUnsubscribe) { - this._dataManagerVisibilityRequestUnsubscribe(); - this._dataManagerVisibilityRequestUnsubscribe = null; - } this._dataManager = dataManager || null; this.hud.attachDataManager(this._dataManager); this._updateTrafficSyncChip(); @@ -3952,123 +3900,14 @@ export class StyleManager { this._dataManagerUnsubscribe(); this._dataManagerUnsubscribe = null; } + this._contextControls.connect(this._dataManager); if (typeof this._dataManager?.subscribe === 'function') { this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => { - if (String(change?.type || '').startsWith('visibility')) { - this._handleContextLayerChange(change); - } this._loadingFeedbackEvent = change; this._updateGlobalLoadingFeedback(performance.now()); }); } this._updateGlobalLoadingFeedback(performance.now()); - if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { - this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => { - if (shouldCaptureContextSession(change)) { - // This event is synchronous with intent publication, before an - // awaited guard or Clear All can alter the rest of the layer set. - // Manager effective visibility already includes both the new entry - // intent and Clear's reserved OFF baseline. - this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] }); - if (shouldDeferContextEntryDuringClear({ - change, - clearInFlight: Boolean(this._clearSelectedLayersPromise), - })) { - this._contextModeDeferredEntryIntent = { - layerId: change.layerId, - intentEpoch: change.intentEpoch, - origin: change.origin, - }; - this._contextModeEntering = 'space-missions'; - this._syncContextModeButtons(); - } - } else if ( - change?.layerId === 'rocket-launches' - && change.enabled === false - && isExplicitUserIntentOrigin(change.origin, change.layerId) - ) { - this._contextModeDeferredEntryIntent = null; - if (this._clearSelectedLayersPromise) { - this._contextSessionSnapshot = null; - this._contextModeEntering = null; - this._syncContextModeButtons(); - } - } - }); - } - if (typeof this._dataManager?.addVisibilityGuard === 'function') { - this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => { - const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId; - const reason = contextLayerEnableBlockReason({ - contextMode: this._contextModeEntering || this._contextMode, - change, - layerName, - }); - if (reason) return reason; - if ( - change.enabled - && ['military-awareness', 'rocket-launches'].includes(change.layerId) - && shouldCaptureContextSession(change) - && ( - !this._contextModeChanging - || ( - change.layerId === 'rocket-launches' - && this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch - ) - ) - ) { - const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null; - const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch; - // A deferred entry owns the state after Clear settles. Restoring - // Clear's transient busy flag here would leave Context stuck. - const priorChanging = deferredClearEntry ? false : this._contextModeChanging; - const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry'); - const ownsNotificationToken = !change.notificationToken; - if (ownsNotificationToken) { - this._userFacingContextNotificationTokens.add(notificationToken); - } - this._contextModeEntering = entryMode; - this._contextModeChanging = true; - try { - if (deferredClearEntry) { - await this._clearSelectedLayersManagerPromise; - if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; - this._contextModeDeferredEntryIntent = null; - } - await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); - } catch (error) { - this._contextModeEntering = null; - console.warn(`[Context] ${change.layerId} isolation failed`, error); - try { - await this._restoreContextSession({ - excludeLayerIds: [change.layerId], - notificationToken, - }); - } catch (restoreError) { - console.warn(`[Context] ${change.layerId} rollback failed`, restoreError); - } - return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`; - } finally { - if (ownsNotificationToken) { - this._userFacingContextNotificationTokens.delete(notificationToken); - } - settleContextModeChange(this, priorChanging); - } - } - return null; - }); - } - if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { - this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { - if (!this._contextSessionSnapshot) return; - await runWithContextModeChanging(this, async () => { - this._contextMode = null; - this.cockpitView?.exit({ restoreTracking: false }); - this._syncContextModeButtons(); - await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] }); - }); - }); - } this._syncContextModeButtons(); this._cctvControls.connect(); this._radioControls.connect(); @@ -4176,712 +4015,40 @@ export class StyleManager { showAfterStartupCover(); } + get _contextMode() { return this._contextControls?._contextMode ?? null; } + get _contextModeChanging() { return this._contextControls?._contextModeChanging ?? false; } + get _preservePanelStateDuringLayerClear() { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; } + _initGlobalContextPanel() { - const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean); - contextTabs.forEach((tab, index) => tab.addEventListener('keydown', (event) => { - let nextIndex = null; - if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length; - else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length; - else if (event.key === 'Home') nextIndex = 0; - else if (event.key === 'End') nextIndex = contextTabs.length - 1; - if (nextIndex === null) return; - event.preventDefault(); - contextTabs[nextIndex].focus({ preventScroll: true }); - contextTabs[nextIndex].click(); - })); - this._globalContextFlightsBtn?.addEventListener('click', () => { - if (this._contextModeChanging || this._clearSelectedLayersPromise) return; - const nextMode = this._contextMode === 'flights' ? null : 'flights'; - this._claimContextVisualAuthority(); - void this._runUserFacingContextAction( - (notificationToken) => this._selectContextMode( - nextMode, - { notificationToken }, - ), - 'Contacts could not complete the requested transition; try again', - ).then((succeeded) => { - if (nextMode && shouldExpandGlobalContextPanel({ - action: 'contacts', - explicitUserAction: true, - succeeded: succeeded === true, - })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); - }); - this._globalContextMissionsBtn?.addEventListener('click', () => { - if (this._contextModeChanging || this._clearSelectedLayersPromise) return; - const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; - this._claimContextVisualAuthority(); - void this._runUserFacingContextAction( - (notificationToken) => this._selectContextMode( - nextMode, - { notificationToken }, - ), - 'Space Missions could not complete the requested transition; try again', - ).then((succeeded) => { - if (nextMode && shouldExpandGlobalContextPanel({ - action: 'space-missions', - explicitUserAction: true, - succeeded: succeeded === true, - })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); - }); - this._installationsSearchBtn?.addEventListener('click', () => { - if (!this._dataManager?.layers?.has('military-installations')) return; - const button = this._installationsSearchBtn; - if (button.getAttribute('aria-busy') === 'true') return; - button.setAttribute('aria-disabled', 'true'); - button.setAttribute('aria-busy', 'true'); - void this._runUserFacingContextAction(async (notificationToken) => { - const enabled = await this._dataManager.setEnabled('military-installations', true, { - origin: 'user', - notificationToken, - }); - if (enabled === false || !this._dataManager.isEnabled('military-installations')) return false; - const searched = await militaryInstallationsLayer.searchNearby?.(); - if (searched === false) return false; - const stats = militaryInstallationsLayer.getStats?.(); - this._showToast(stats?.statusMessage || (stats?.status === 'zoom-in' - ? 'Zoom in to search mapped installations' - : 'Nearby installations refreshed')); - return true; - }, 'Nearby installations could not be refreshed; try again').finally(() => { - button.setAttribute('aria-disabled', 'false'); - button.setAttribute('aria-busy', 'false'); - }); + this._contextControls = new ContextControls({ + elements: { + _globalContextPanel: document.getElementById('global-context-panel'), + _globalContextFlightsBtn: this._globalContextFlightsBtn, + _globalContextMissionsBtn: this._globalContextMissionsBtn, + _contextModeStandby: this._contextModeStandby, + _contextFlightsView: this._contextFlightsView, + _contextMissionsView: this._contextMissionsView, + _installationsSearchBtn: this._installationsSearchBtn, + }, + installations: militaryInstallationsLayer, + actions: { + getCockpit: () => this.cockpitView, + refreshRadio: () => this._renderRadioState(radioLayer.getUIState()), + claimVisualAuthority: () => this.shareLinkManager?.claimRestoreLane?.('visual'), + syncDetection: () => this._syncContactsDetection(), + scheduleLayout: () => this._scheduleRightPanelLayout(), + setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + showToast: (message) => { if (!this._disposed) this._showToast(message); }, + setClearBusy: (busy) => { if (!this._disposed) this._clearLayersControl?.setBusy(busy); }, + }, }); } - async _runUserFacingContextAction( - operation, - message = 'Context could not restore every layer; try again', - { falseIsFailure = true } = {}, - ) { - const notificationToken = Symbol('user-facing-context-action'); - this._userFacingContextNotificationTokens.add(notificationToken); - try { - return await settleUserFacingContextAction({ - operation: () => operation(notificationToken), - falseIsFailure, - onFailure: (error) => { - console.warn('[Context] user-facing transition failed', error); - this._showToast(message); - }, - }); - } finally { - this._userFacingContextNotificationTokens.delete(notificationToken); - } - } + _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); } - _trackContextLayerReaction(promise) { - const tracked = Promise.resolve(promise); - this._contextLayerReactionPromises.add(tracked); - void tracked.finally(() => this._contextLayerReactionPromises.delete(tracked)); - return tracked; - } + _waitForContextLayerSettlement(...args) { return this._contextControls?._waitForContextLayerSettlement(...args); } - async _waitForContextLayerSettlement() { - while (this._contextLayerReactionPromises.size > 0) { - await Promise.allSettled([...this._contextLayerReactionPromises]); - } - } - - _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { - if (!this._dataManager || this._contextSessionSnapshot) return; - const params = {}; - for (const layerId of ['military-awareness', 'satellites']) { - const value = this._dataManager.getLayerParams(layerId); - if (value) params[layerId] = value; - } - this._contextSessionSnapshot = { - enabledLayerIds: contextSnapshotLayerIds( - this._dataManager.getEnabledLayerIds(), - this._contextRestoreState?.enabledLayerIds, - excludeLayerIds, - ), - userAdded: new Set(), - userRemoved: new Set(), - params, - }; - } - - async _restoreContextSession({ - excludeLayerIds = [], - notificationToken = null, - signal = null, - } = {}) { - const snapshot = this._contextSessionSnapshot; - if (!snapshot || !this._dataManager) return; - // Clear the stored session before emitting restore notifications so none - // of those transitions can be mistaken for a fresh Context entry. - this._contextSessionSnapshot = null; - const restoreState = { - enabledLayerIds: contextRestoreLayerIds(snapshot), - explicitLayerStates: new Map(), - }; - this._contextRestoreState = restoreState; - for (const [layerId, params] of Object.entries(snapshot.params)) { - this._dataManager.setLayerParams(layerId, params); - } - let restoreError = null; - const restoreSnapshot = async (restoreSignal = null) => { - // Contacts owns the dependency intents it starts. Settle that - // coordinator before restoring the remaining snapshot, otherwise its - // dependency releases can supersede the restore's same-target requests - // and make a valid Contacts-to-Missions handoff look like a failure. - const contactsCoordinatorId = 'military-awareness'; - const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId) - && this._dataManager.isEffectivelyEnabled(contactsCoordinatorId); - if (settleContactsCoordinator) { - const coordinatorSettled = await this._dataManager.setEnabled( - contactsCoordinatorId, - false, - { - origin: 'context-restore', - ...(notificationToken ? { notificationToken } : {}), - ...(restoreSignal ? { signal: restoreSignal } : {}), - }, - ); - if (coordinatorSettled === false) { - const error = new Error('Failed to settle Contacts before restoring Context'); - error.failedLayerIds = [contactsCoordinatorId]; - throw error; - } - } - await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, { - origin: 'context-restore', - excludeLayerIds: settleContactsCoordinator - ? [...excludeLayerIds, contactsCoordinatorId] - : excludeLayerIds, - notificationToken, - ...(restoreSignal ? { signal: restoreSignal } : {}), - }); - }; - try { - await restoreSnapshot(signal); - } catch (error) { - restoreError = error; - // A caller abort can arrive after only part of the exact restore has - // settled. Finish that same target without the stale caller signal while - // this restoreState still records newer explicit intents; replay below - // then gives those newer intents final authority. - if (signal?.aborted && !restoreState.cancelled) { - try { - await restoreSnapshot(null); - restoreError = null; - } catch (compensationError) { - restoreError = mergeContextTransitionErrors(restoreError, compensationError); - } - } - } finally { - if (this._contextRestoreState === restoreState) this._contextRestoreState = null; - } - // Clear Selected Layers owns a newer global OFF intent. A restore that was - // already awaiting lifecycle work must not replay its captured companion - // intent or recreate the discarded session after Clear invalidates it. - if (restoreState.cancelled) return; - // A direct Radio command may finish after restore has already copied its - // target and queued the opposite state. Replay that newer intent only - // after the stale queue drains; the replay origin cannot recurse here. - const replaySignal = signal?.aborted ? null : signal; - const replayError = await settleContextIntentReplay({ - restoreState, - setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled( - layerId, - enabled, - { ...options, ...(replaySignal ? { signal: replaySignal } : {}) }, - ), - notificationToken, - }); - restoreError = mergeContextTransitionErrors(restoreError, replayError); - if (restoreError && !this._contextSessionSnapshot) { - // Keep the exact still-pending target so a later exit/teardown can retry - // instead of silently losing the user's pre-Context layer state. - this._contextSessionSnapshot = { - enabledLayerIds: new Set(restoreState.enabledLayerIds), - userAdded: new Set(), - userRemoved: new Set(), - params: snapshot.params, - }; - } - if (restoreError) throw restoreError; - } - - /* - * Cross-mode cancellation and failure deliberately settle on Context OFF. - * - * A reinstatement transaction lived here for three review rounds and was - * removed on purpose. Restoring the prior mode is genuinely racy: the prior - * mode has to be read before the teardown, but a second request arriving - * while the first reinstatement is mid-activation reads `_contextMode` as - * null and inherits nothing, so two overlapping cancellations still land on - * OFF — and the only fix is a cross-transaction "logical prior mode" chain, - * which is new shared mutable state read while an earlier transaction is - * still awaiting. That trades a rare wrong resting state for a permanent - * interleaving hazard. - * - * The defect that started this was the LIE, not the OFF: the transition - * claimed to have cancelled cleanly while silently leaving Context off. So - * the resting state stays OFF and is REPORTED as such, with the failed layer - * ids preserved. A restore feature can be rebuilt post-launch on the - * generation discipline the surrounding transaction already follows. - */ - - async _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) { - await this._dataManager?.waitForLayerSettled?.(layerId); - return this._restoreContextSession({ notificationToken }); - } - - /** - * Claim the visual restore lane for an explicit Context transition. - * - * Contacts OWNS detection while active (forced Dense @ 75%), so entering or - * leaving it is a visual-lane gesture exactly like the HUD or detection - * controls. Without this claim, the shared-view restore that lands 1.5 s into - * startup re-applied the link's `dm`/`dd` over the forced preset and Contacts - * lost its own overlay mid-session. - * - * Deliberately does NOT set `_detectionUserOverridden`: that flag means the - * OPERATOR hand-edited detection and suppresses the military-style - * auto-enable for the rest of the session. Context entry is not that, and - * conflating them would silently disable a separate landed behavior. - * - * Call only for VALIDATED explicit transitions — never for programmatic or - * restore-driven ones, which must stay eligible for the shared visual state. - */ - _claimContextVisualAuthority() { - this.shareLinkManager?.claimRestoreLane?.('visual'); - } - - async _selectContextMode(mode, { notificationToken = null, signal = null } = {}) { - if (!this._dataManager) return false; - if (this._clearSelectedLayersPromise) return false; - this._contextTransitionFailedLayerIds = []; - const generation = ++this._contextModeGeneration; - const isCurrent = () => generation === this._contextModeGeneration; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextModeChanging = true; - this._syncContextModeButtons(); - try { - if (mode !== 'flights' && this.cockpitView?.active) { - this.cockpitView.exit({ restoreTracking: false }); - } - if (!mode) { - this._contextMode = null; - this._syncContextModeButtons(); - await this._restoreContextSession({ notificationToken, signal }); - return isCurrent(); - } - // A cross-mode switch dismantles the prior mode BEFORE the new one is - // committed. If the caller aborts in that window the switch never lands, - // and the resting state is Context OFF — reported as such by - // setContextMode rather than dressed up as a clean cancellation. See the - // note above _restoreContextSessionAfterLayerSettles. - const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode); - if (crossModeSwitch) { - this._contextMode = null; - await this._restoreContextSession({ notificationToken, signal }); - if (!isCurrent()) return false; - if (signal?.aborted) return false; - } - this._captureContextSessionSnapshot(); - this._contextMode = mode; - this._syncContextModeButtons(); - // Replay isolation must settle before Space Missions starts. Contacts - // keeps its non-dependency teardown in the background so slow source - // shutdown does not delay cockpit entry. - try { - await this._clearLayersOutsideContextMode(mode, { notificationToken, signal }); - } catch (error) { - if (!isCurrent()) return false; - let transitionError = error; - console.warn(`[Context] ${mode} isolation failed`, error); - this._contextMode = null; - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ notificationToken }); - } catch (restoreError) { - transitionError = mergeContextTransitionErrors(transitionError, restoreError); - this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])]; - throw transitionError; - } - this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; - return false; - } - if (!isCurrent()) return false; - // Entry is one transaction: isolation succeeded above, so a failed mode - // activation must roll the cleared layers back instead of stranding the - // user in a half-entered mode with an orphaned snapshot. - const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches'; - if (mode === 'flights') { - this._dataManager.setLayerParams('military-awareness', { passive: false }); - } - let activated = false; - let activationError = null; - let activationIntent = null; - let terminalIntentOutcome = null; - try { - activationIntent = this._dataManager._setEnabledWithIntent( - entryLayerId, - true, - { notificationToken, ...(signal ? { signal } : {}) }, - ); - this._contextModeEntryIntent = { - generation, - layerId: entryLayerId, - intentEpoch: activationIntent.intentEpoch, - }; - activated = await activationIntent.promise; - terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.( - entryLayerId, - activationIntent.intentEpoch, - ); - } catch (error) { - activationError = error; - } - if (!isCurrent()) return false; - let replacementIntent = mode === 'space-missions' - && this._contextModeReplacementIntent?.generation === generation - && this._contextModeReplacementIntent.layerId === entryLayerId - ? this._contextModeReplacementIntent - : null; - while (replacementIntent) { - const outcome = await this._dataManager._waitForVisibilityIntent?.( - entryLayerId, - replacementIntent.intentEpoch, - ); - terminalIntentOutcome = outcome; - if (!isCurrent()) return false; - const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch - && outcome.enabled === true - && outcome.succeeded === true; - if (replacementOwnsMode) { - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._syncContextModeButtons(); - return true; - } - const successorEpoch = outcome?.cancellationReason === 'superseded' - && outcome.successorEnabled === true - && Number.isInteger(outcome.successorIntentEpoch) - && outcome.successorIntentEpoch > replacementIntent.intentEpoch - ? outcome.successorIntentEpoch - : null; - replacementIntent = successorEpoch === null ? null : { - generation, - layerId: entryLayerId, - intentEpoch: successorEpoch, - }; - } - if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) { - const cancelledAndSettled = terminalIntentOutcome?.succeeded === false - && ['caller-abort', 'resource-abort', 'superseded'].includes( - terminalIntentOutcome.cancellationReason, - ); - let transitionError = null; - if (!cancelledAndSettled) { - transitionError = activationError instanceof Error - ? activationError - : new Error(`Context activation failed for: ${entryLayerId}`); - transitionError.failedLayerIds = [...new Set([ - ...(transitionError.failedLayerIds || []), - entryLayerId, - ])]; - this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds]; - console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled'); - } - this._contextMode = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ - excludeLayerIds: [entryLayerId], - notificationToken, - }); - } catch (restoreError) { - transitionError = mergeContextTransitionErrors(transitionError, restoreError); - this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; - throw transitionError; - } - // `null` means the requested entry was cancelled and its exact rollback - // completed. The action wrapper treats that as a silent non-commit, - // while callers still require literal `true` before expanding Context. - return cancelledAndSettled ? null : false; - } - this._contextModeEntryIntent = null; - return true; - } finally { - if (isCurrent()) { - this._contextModeChanging = false; - this._syncContextModeButtons(); - } - } - } - - async _deactivateContextForLayerChange({ notificationToken = null } = {}) { - this._contextModeGeneration += 1; - this._contextModeChanging = true; - this._contextMode = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false }); - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ notificationToken }); - } finally { - this._contextModeChanging = false; - this._syncContextModeButtons(); - } - } - - async _clearLayersOutsideContextMode( - mode = null, - { notificationToken = null, signal = null } = {}, - ) { - const allowed = contextAllowedLayerIds(mode); - const pending = []; - for (const [layerId] of this._dataManager.layers || []) { - // Effective visibility: a disallowed layer still mid-ENABLING must be - // isolated too, or it settles ON inside the exclusive mode. - if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) { - pending.push({ - layerId, - transition: this._dataManager.setEnabled(layerId, false, { - notificationToken, - ...(signal ? { signal } : {}), - }), - }); - } - } - const results = await Promise.all(pending.map(({ transition }) => transition)); - const failed = pending - .filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId)) - .map(({ layerId }) => layerId); - if (failed.length > 0) { - const error = new Error(`Context isolation failed for: ${failed.join(', ')}`); - error.failedLayerIds = failed; - throw error; - } - } - - _handleContextLayerChange(change) { - if (change?.layerId === 'radio' && [ - 'visibility-transition', - 'visibility', - 'visibility-cancelled', - 'visibility-failed', - ].includes(change.type)) { - this._renderRadioState(radioLayer.getUIState()); - } - if (change?.type === 'visibility-transition') return; - // The effective mode must be read BEFORE the entering flag is cleared: - // the entry layer's own enable event is the one that clears it, and the - // session bookkeeping below needs to know a mode was being entered. - const effectiveContextMode = this._contextModeEntering || this._contextMode; - if (change?.type === 'visibility-cancelled') { - const cancellationDisposition = spaceMissionEntryCancellationDisposition({ - change, - }); - if ( - this._contextModeDeferredEntryIntent?.layerId === change.layerId - && this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch - ) { - if (cancellationDisposition !== 'replacement') { - this._contextModeDeferredEntryIntent = null; - } - } - if (cancellationDisposition === 'replacement') { - this._contextModeEntering = 'space-missions'; - const entryIntent = this._contextModeEntryIntent; - if ( - entryIntent?.generation === this._contextModeGeneration - && entryIntent.layerId === change.layerId - && entryIntent.intentEpoch === change.intentEpoch - ) { - this._contextModeReplacementIntent = { - generation: entryIntent.generation, - layerId: change.layerId, - intentEpoch: change.successorIntentEpoch, - }; - } - } else if (cancellationDisposition === 'restore') { - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - if (this._contextSessionSnapshot && !this._contextModeChanging) { - this._contextMode = null; - void this._trackContextLayerReaction(this._runUserFacingContextAction( - async (notificationToken) => { - await this._restoreContextSessionAfterLayerSettles( - change.layerId, - { notificationToken }, - ); - return true; - }, - 'Space Missions cancellation could not restore the previous layer state', - )); - } - } - this._syncContextModeButtons(); - return; - } - if ( - change?.layerId === 'rocket-launches' - && ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type) - ) { - this._contextModeEntering = null; - } - if (change?.type === 'visibility-blocked') { - if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { - this._showToast(change.reason || 'That layer is unavailable in the current Context mode'); - } - this._syncContextModeButtons(); - return; - } - if (change?.type === 'visibility-failed') { - const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`; - // A failed direct Context-shell START has already had its siblings - // cleared by the visibility guard. Wait outside the synchronous manager - // notification for this queue to settle, then reconcile the complete - // snapshot, including an uncertain failed shell. - const needsDeferredShellRestore = ( - ['military-awareness', 'rocket-launches'].includes(change.layerId) - && change.enabled - && this._contextSessionSnapshot - && !this._contextModeChanging - ); - if (needsDeferredShellRestore) { - this._contextMode = null; - void this._trackContextLayerReaction(this._runUserFacingContextAction( - async (notificationToken) => { - await this._restoreContextSessionAfterLayerSettles( - change.layerId, - { notificationToken }, - ); - // The wrapper owns failure announcements. On a successful rollback - // announce the original activation failure here so the same direct - // action still produces exactly one accessible notification. - this._showToast(failureMessage); - return true; - }, - failureMessage, - )); - } else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { - this._showToast(failureMessage); - } - this._syncContextModeButtons(); - return; - } - if (change?.type === 'visibility-will-change') { - // Explicit entry capture happens on the synchronous visibility-requested - // boundary. Keeping this later branch side-effect free prevents an - // awaited Clear/guard from replacing that authoritative pre-entry view. - return; - } - // Session bookkeeping must run BEFORE any exit path below: the exit - // handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes - // a layer resurrected against the user's explicit disable. - recordContextSessionUserChange({ - snapshot: this._contextSessionSnapshot, - change, - effectiveContextMode, - }); - recordContextRestoreExplicitChange({ - restoreState: this._contextRestoreState, - change, - }); - if (shouldExitContextForLayerChange({ - contextMode: this._contextMode, - globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'), - change, - })) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - return; - } - if (!this._contextModeChanging) { - if (change.layerId === 'military-awareness') { - // The coordinator remains manager-addressable for restoration and - // programmatic routes, but Contacts is selected only from the - // dedicated right-side Global Context chooser. - this._contextMode = change.enabled - ? null - : (this._contextMode === 'flights' ? null : this._contextMode); - if (change.enabled) { - this._syncContextModeButtons(); - } else if (this._contextSessionSnapshot) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } else if (change.layerId === 'rocket-launches') { - const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId) - || this._contextMode === 'space-missions' - || effectiveContextMode === 'space-missions'; - if (!ownsContextEntry) return; - this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode); - if (change.enabled) { - this._syncContextModeButtons(); - } else if (this._contextSessionSnapshot) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } else if ( - this._contextMode === 'flights' - && ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId) - && !change.enabled - ) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } - if (this.cockpitView?.active && !cockpitEntryAllowed({ - contextMode: this._contextMode, - contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - })) { - this.cockpitView.exit({ restoreTracking: false }); - } - this._syncContextModeButtons(); - } - - _syncContextModeButtons() { - const flightsActive = this._contextMode === 'flights'; - const missionsActive = this._contextMode === 'space-missions'; - const panel = document.getElementById('global-context-panel'); - panel?.classList.toggle('context-enabled', flightsActive || missionsActive); - panel?.setAttribute('data-context-mode', this._contextMode || 'none'); - this._globalContextFlightsBtn?.classList.toggle('active', flightsActive); - this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive)); - this._globalContextMissionsBtn?.classList.toggle('active', missionsActive); - this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive)); - const transitionBusy = Boolean(this._contextModeChanging); - // Both Context choices stay in the ordinary Tab sequence. Arrow keys still - // provide tablist navigation, but must not be the only way to reach Space - // Missions from the keyboard. Semantic busy state keeps them perceivable - // while synchronous click guards prevent a second transition. - for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) { - if (!button) continue; - button.disabled = false; - button.tabIndex = 0; - button.setAttribute('aria-disabled', String(transitionBusy)); - button.setAttribute('aria-busy', String(transitionBusy)); - } - if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive; - if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive; - if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive; - this.cockpitView?.syncEntry(); - // Every _contextMode mutation funnels through here; the sync no-ops until - // the transaction settles, so this is the activation/deactivation edge. - this._syncContactsDetection(); - this._scheduleRightPanelLayout(); - } + _syncContextModeButtons(...args) { return this._contextControls?._syncContextModeButtons(...args); } /** Wire the independent Radio companion controls. */ _initRadioPanel() { @@ -5020,8 +4187,6 @@ export class StyleManager { }); } - - /** * Toggles the CCTV layer enabled state. When enabling and no camera is active, * auto-focuses on the nearest camera. @@ -5055,7 +4220,6 @@ export class StyleManager { return true; } - /** * Returns the versioned localStorage key for a panel's saved position. * @param {string} panelId - DOM id of the panel. @@ -6104,17 +5268,7 @@ export class StyleManager { * Reads global context mode state for voice/state-sync consumers. * @returns {{mode: 'flights'|'space-missions'|null, active: boolean, changing: boolean, entering: 'flights'|'space-missions'|null, snapshotCaptured: boolean}} */ - getContextModeState() { - return { - mode: this._contextMode || null, - active: Boolean(this._contextMode), - changing: Boolean(this._contextModeChanging), - entering: this._contextModeEntering || null, - canContact: !this._contextMode || this._contextMode === 'flights', - canMission: !this._contextMode || this._contextMode === 'space-missions', - snapshotCaptured: Boolean(this._contextSessionSnapshot), - }; - } + getContextModeState(...args) { return this._contextControls?.getContextModeState(...args); } /** * Sets global context mode (Contacts / Space Missions / off) for voice. @@ -6130,107 +5284,7 @@ export class StyleManager { * operator and must stay inert, so they pass `false`. * @returns {Promise<{ok:boolean, mode:'flights'|'space-missions'|null, active:boolean, action:string, error?:string}>} */ - async setContextMode(mode, { - notificationToken = null, - signal = null, - isCurrent = null, - claimVisualAuthority = true, - } = {}) { - const requestIsCurrent = () => !signal?.aborted - && (typeof isCurrent !== 'function' || isCurrent()); - const cancellationResult = () => ({ - ok: false, - action: 'set_context_mode', - cancelled: true, - error: 'Context request was superseded by a newer voice turn', - ...this.getContextModeState(), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }); - if (!requestIsCurrent()) return cancellationResult(); - try { - if (!mode || mode === 'off') { - // Validated explicit transition — Context owns detection, so take the - // visual lane before a delayed shared restore can reclaim it. Internal - // Cockpit choreography opts out: it is not an operator Context request. - if (claimVisualAuthority) this._claimContextVisualAuthority(); - const result = await this._selectContextMode(null, { notificationToken, signal }); - if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult(); - const state = this.getContextModeState(); - return { - ok: result === true, - action: 'set_context_mode', - mode: state.mode, - ...state, - ...(result === true ? {} : { error: 'Context mode transition did not complete' }), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }; - } - const canonical = mode === 'contacts' ? 'flights' : mode; - if (!['flights', 'space-missions'].includes(canonical)) { - return { - ok: false, - action: 'set_context_mode', - error: `Unknown context mode: ${mode}`, - mode: this._contextMode, - ...this.getContextModeState(), - }; - } - const priorMode = this._contextMode; - // Claimed only after the mode enum validates above, so a rejected request - // takes no authority and leaves the shared visual state eligible. Internal - // Cockpit choreography opts out: it is not an operator Context request. - if (claimVisualAuthority) this._claimContextVisualAuthority(); - const transitioned = await this._selectContextMode(canonical, { notificationToken, signal }); - if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult(); - const state = this.getContextModeState(); - // A cross-mode switch tears the prior mode down before it commits, so a - // cancelled or failed switch rests on Context OFF. Say that plainly: - // reporting a bare "did not complete" while the operator's Context is - // gone is the dishonesty this whole path was fixed for. The state fields - // below carry the same verdict, so text and state cannot disagree. - const crossModeSwitchLost = transitioned !== true - && Boolean(priorMode) && priorMode !== canonical && !state.mode; - return { - ok: transitioned === true, - action: 'set_context_mode', - mode: state.mode, - ...state, - ...(transitioned === true ? {} : { - // Named in the operator's vocabulary, not the internal id: this - // string is read by the voice model, which takes 'contacts'. - error: crossModeSwitchLost - ? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off` - : 'Context mode transition did not complete', - ...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}), - }), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }; - } catch (error) { - if (!requestIsCurrent()) { - return { - ...cancellationResult(), - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), - }; - } - return { - ok: false, - action: 'set_context_mode', - error: error?.message || 'Context mode transition failed', - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), - ...this.getContextModeState(), - }; - } - } + setContextMode(...args) { return this._contextControls?.setContextMode(...args); } /** * Returns cockpit status for voice/state sync and navigation operations. @@ -7441,64 +6495,7 @@ export class StyleManager { * established disable lifecycle. * @returns {Promise} Aggregate manager lifecycle truth for the batch. */ - clearSelectedLayers() { - if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise; - if (!this._dataManager?.clearSelectedLayers) { - return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [] }); - } - const generation = ++this._contextModeGeneration; - const notificationToken = Symbol('clear-selected-layers'); - if (this._contextRestoreState) this._contextRestoreState.cancelled = true; - this._contextModeChanging = true; - this._contextMode = null; - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextSessionSnapshot = null; - this._contextRestoreState = null; - this._preservePanelStateDuringLayerClear = true; - this._syncContextModeButtons(); - this._userFacingContextNotificationTokens.add(notificationToken); - this._clearLayersControl?.setBusy(true); - - const managerOperation = this._dataManager.clearSelectedLayers({ - origin: 'user', - notificationToken, - }); - this._clearSelectedLayersManagerPromise = managerOperation; - const operation = managerOperation.then((result) => { - if (result.targetIds.length === 0) { - this._showToast('No selected data layers'); - } else if (result.notClearedIds.length > 0) { - this._showToast(`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`); - } else { - this._showToast(`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`); - } - return result; - }).catch((error) => { - console.warn('[Data] clear selected layers failed', error); - this._showToast('Selected data layers could not be cleared'); - return { - targetIds: [], - items: [], - clearedIds: [], - notClearedIds: [], - error, - }; - }).finally(() => { - this._userFacingContextNotificationTokens.delete(notificationToken); - if (generation === this._contextModeGeneration) { - this._contextModeChanging = false; - this._syncContextModeButtons(); - } - this._clearLayersControl?.setBusy(false); - this._preservePanelStateDuringLayerClear = false; - this._clearSelectedLayersManagerPromise = null; - this._clearSelectedLayersPromise = null; - }); - this._clearSelectedLayersPromise = operation; - return operation; - } + clearSelectedLayers(...args) { return this._contextControls?.clearSelectedLayers(...args); } /** * Release every camera owner and return to the canonical full-globe frame. @@ -7942,7 +6939,7 @@ export class StyleManager { // would. Without this, a reinstatement already past its awaits could // re-enable a mode's entry layer and republish `_contextMode` while the // rest of teardown is tearing those very layers down. - this._contextModeGeneration += 1; + this._contextControls.stop(); this._stampNavigation(); // Close camera-entry seams synchronously. Context restoration may await // layer work, so leaving these listeners attached until afterward lets a @@ -7957,9 +6954,7 @@ export class StyleManager { this._navigationOwnerChangedRemover = null; this._removeNavigationAuthorityListener?.(); this._removeNavigationAuthorityListener = null; - this._contextModeChanging = true; - this._contextMode = null; - await this._restoreContextSession(); + await this._contextControls.restoreForDisposal(); // IR boost teardown BEFORE detaching the data manager: restore fog and // un-boost both aircraft layers so a surviving viewer or replacement // manager doesn't inherit sensor state (review P2, 2026-08-16). @@ -7984,12 +6979,7 @@ export class StyleManager { this._cockpitDisplayScrollHandler = null; for (const record of this._cockpitDisplayPortalRecords) record.anchor.remove(); this._cockpitDisplayPortalRecords = []; - this._dataManagerBeforeDestroyUnsubscribe?.(); - this._dataManagerBeforeDestroyUnsubscribe = null; - this._dataManagerVisibilityGuardUnsubscribe?.(); - this._dataManagerVisibilityGuardUnsubscribe = null; - this._dataManagerVisibilityRequestUnsubscribe?.(); - this._dataManagerVisibilityRequestUnsubscribe = null; + this._contextControls.disconnect(); this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; diff --git a/src/ui/context.js b/src/ui/context.js new file mode 100644 index 000000000..76a6e9025 --- /dev/null +++ b/src/ui/context.js @@ -0,0 +1 @@ +export { ContextControls } from './contextControls.js'; diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js new file mode 100644 index 000000000..4be137f07 --- /dev/null +++ b/src/ui/contextActions.js @@ -0,0 +1,219 @@ +import { settleUserFacingContextAction, contextModeWord } from '../contextModePolicy.js'; + +export async function _runUserFacingContextAction( + operation, + message = 'Context could not restore every layer; try again', + { falseIsFailure = true } = {}, + ) { + if (this.destroyed) return false; + const notificationToken = Symbol('user-facing-context-action'); + this._userFacingContextNotificationTokens.add(notificationToken); + try { + return await settleUserFacingContextAction({ + operation: () => operation(notificationToken), + falseIsFailure, + onFailure: (error) => { + if (this.destroyed) return; + console.warn('[Context] user-facing transition failed', error); + this.showToast(message); + }, + }); + } finally { + this._userFacingContextNotificationTokens.delete(notificationToken); + } + } + +/** + * Claim the visual restore lane for an explicit Context transition. + * + * Contacts OWNS detection while active (forced Dense @ 75%), so entering or + * leaving it is a visual-lane gesture exactly like the HUD or detection + * controls. Without this claim, the shared-view restore that lands 1.5 s into + * startup re-applied the link's `dm`/`dd` over the forced preset and Contacts + * lost its own overlay mid-session. + * + * Deliberately does NOT set `_detectionUserOverridden`: that flag means the + * OPERATOR hand-edited detection and suppresses the military-style + * auto-enable for the rest of the session. Context entry is not that, and + * conflating them would silently disable a separate landed behavior. + * + * Call only for VALIDATED explicit transitions — never for programmatic or + * restore-driven ones, which must stay eligible for the shared visual state. + */ +export function _claimContextVisualAuthority() { + this.actions.claimVisualAuthority(); + } + +export function getContextModeState() { + return { + mode: this._contextMode || null, + active: Boolean(this._contextMode), + changing: Boolean(this._contextModeChanging), + entering: this._contextModeEntering || null, + canContact: !this._contextMode || this._contextMode === 'flights', + canMission: !this._contextMode || this._contextMode === 'space-missions', + snapshotCaptured: Boolean(this._contextSessionSnapshot), + }; + } + +export async function setContextMode(mode, { + notificationToken = null, + signal = null, + isCurrent = null, + claimVisualAuthority = true, + } = {}) { + const requestIsCurrent = () => !this.destroyed && !signal?.aborted + && (typeof isCurrent !== 'function' || isCurrent()); + const cancellationResult = () => ({ + ok: false, + action: 'set_context_mode', + cancelled: true, + error: 'Context request was superseded by a newer voice turn', + ...this.getContextModeState(), + ...(this._contextTransitionFailedLayerIds?.length + ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } + : {}), + }); + if (!requestIsCurrent()) return cancellationResult(); + try { + if (!mode || mode === 'off') { + // Validated explicit transition — Context owns detection, so take the + // visual lane before a delayed shared restore can reclaim it. Internal + // Cockpit choreography opts out: it is not an operator Context request. + if (claimVisualAuthority) this._claimContextVisualAuthority(); + const result = await this._selectContextMode(null, { notificationToken, signal }); + if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult(); + const state = this.getContextModeState(); + return { + ok: result === true, + action: 'set_context_mode', + mode: state.mode, + ...state, + ...(result === true ? {} : { error: 'Context mode transition did not complete' }), + ...(this._contextTransitionFailedLayerIds?.length + ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } + : {}), + }; + } + const canonical = mode === 'contacts' ? 'flights' : mode; + if (!['flights', 'space-missions'].includes(canonical)) { + return { + ok: false, + action: 'set_context_mode', + error: `Unknown context mode: ${mode}`, + mode: this._contextMode, + ...this.getContextModeState(), + }; + } + const priorMode = this._contextMode; + // Claimed only after the mode enum validates above, so a rejected request + // takes no authority and leaves the shared visual state eligible. Internal + // Cockpit choreography opts out: it is not an operator Context request. + if (claimVisualAuthority) this._claimContextVisualAuthority(); + const transitioned = await this._selectContextMode(canonical, { notificationToken, signal }); + if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult(); + const state = this.getContextModeState(); + // A cross-mode switch tears the prior mode down before it commits, so a + // cancelled or failed switch rests on Context OFF. Say that plainly: + // reporting a bare "did not complete" while the operator's Context is + // gone is the dishonesty this whole path was fixed for. The state fields + // below carry the same verdict, so text and state cannot disagree. + const crossModeSwitchLost = transitioned !== true + && Boolean(priorMode) && priorMode !== canonical && !state.mode; + return { + ok: transitioned === true, + action: 'set_context_mode', + mode: state.mode, + ...state, + ...(transitioned === true ? {} : { + // Named in the operator's vocabulary, not the internal id: this + // string is read by the voice model, which takes 'contacts'. + error: crossModeSwitchLost + ? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off` + : 'Context mode transition did not complete', + ...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}), + }), + ...(this._contextTransitionFailedLayerIds?.length + ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } + : {}), + }; + } catch (error) { + if (!requestIsCurrent()) { + return { + ...cancellationResult(), + ...(Array.isArray(error?.failedLayerIds) + ? { failedLayerIds: [...error.failedLayerIds] } + : {}), + }; + } + return { + ok: false, + action: 'set_context_mode', + error: error?.message || 'Context mode transition failed', + ...(Array.isArray(error?.failedLayerIds) + ? { failedLayerIds: [...error.failedLayerIds] } + : {}), + ...this.getContextModeState(), + }; + } + } + +export function clearSelectedLayers() { + if (this.destroyed) return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [], cancelled: true }); + if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise; + if (!this._dataManager?.clearSelectedLayers) { + return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [] }); + } + const generation = ++this._contextModeGeneration; + const notificationToken = Symbol('clear-selected-layers'); + if (this._contextRestoreState) this._contextRestoreState.cancelled = true; + this._contextModeChanging = true; + this._contextMode = null; + this._contextModeEntering = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + this._contextSessionSnapshot = null; + this._contextRestoreState = null; + this._preservePanelStateDuringLayerClear = true; + this._syncContextModeButtons(); + this._userFacingContextNotificationTokens.add(notificationToken); + this.setClearBusy(true); + + const managerOperation = this._dataManager.clearSelectedLayers({ + origin: 'user', + notificationToken, + }); + this._clearSelectedLayersManagerPromise = managerOperation; + const operation = managerOperation.then((result) => { + if (result.targetIds.length === 0) { + this.showToast('No selected data layers'); + } else if (result.notClearedIds.length > 0) { + this.showToast(`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`); + } else { + this.showToast(`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`); + } + return result; + }).catch((error) => { + console.warn('[Data] clear selected layers failed', error); + this.showToast('Selected data layers could not be cleared'); + return { + targetIds: [], + items: [], + clearedIds: [], + notClearedIds: [], + error, + }; + }).finally(() => { + this._userFacingContextNotificationTokens.delete(notificationToken); + if (generation === this._contextModeGeneration) { + this._contextModeChanging = false; + this._syncContextModeButtons(); + } + this.setClearBusy(false); + this._preservePanelStateDuringLayerClear = false; + this._clearSelectedLayersManagerPromise = null; + this._clearSelectedLayersPromise = null; + }); + this._clearSelectedLayersPromise = operation; + return operation; + } diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js new file mode 100644 index 000000000..50b1b9f0c --- /dev/null +++ b/src/ui/contextBindings.js @@ -0,0 +1,77 @@ +import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js'; + +export function _initGlobalContextPanel() { + const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean); + contextTabs.forEach((tab, index) => this.listen(tab, 'keydown', (event) => { + let nextIndex = null; + if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length; + else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length; + else if (event.key === 'Home') nextIndex = 0; + else if (event.key === 'End') nextIndex = contextTabs.length - 1; + if (nextIndex === null) return; + event.preventDefault(); + contextTabs[nextIndex].focus({ preventScroll: true }); + contextTabs[nextIndex].click(); + })); + this.listen(this._globalContextFlightsBtn, 'click', () => { + if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return; + const nextMode = this._contextMode === 'flights' ? null : 'flights'; + this._claimContextVisualAuthority(); + void this._runUserFacingContextAction( + (notificationToken) => this._selectContextMode( + nextMode, + { notificationToken }, + ), + 'Contacts could not complete the requested transition; try again', + ).then((succeeded) => { + if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({ + action: 'contacts', + explicitUserAction: true, + succeeded: succeeded === true, + })) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); + }); + }); + this.listen(this._globalContextMissionsBtn, 'click', () => { + if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return; + const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; + this._claimContextVisualAuthority(); + void this._runUserFacingContextAction( + (notificationToken) => this._selectContextMode( + nextMode, + { notificationToken }, + ), + 'Space Missions could not complete the requested transition; try again', + ).then((succeeded) => { + if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({ + action: 'space-missions', + explicitUserAction: true, + succeeded: succeeded === true, + })) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); + }); + }); + this.listen(this._installationsSearchBtn, 'click', () => { + if (this.destroyed || !this._dataManager?.layers?.has('military-installations')) return; + const button = this._installationsSearchBtn; + if (button.getAttribute('aria-busy') === 'true') return; + button.setAttribute('aria-disabled', 'true'); + button.setAttribute('aria-busy', 'true'); + void this._runUserFacingContextAction(async (notificationToken) => { + const enabled = await this._dataManager.setEnabled('military-installations', true, { + origin: 'user', + notificationToken, + }); + if (this.destroyed || enabled === false || !this._dataManager.isEnabled('military-installations')) return false; + const searched = await this.installations.searchNearby?.(); + if (this.destroyed || searched === false) return false; + const stats = this.installations.getStats?.(); + this.showToast(stats?.statusMessage || (stats?.status === 'zoom-in' + ? 'Zoom in to search mapped installations' + : 'Nearby installations refreshed')); + return true; + }, 'Nearby installations could not be refreshed; try again').finally(() => { + if (this.destroyed) return; + button.setAttribute('aria-disabled', 'false'); + button.setAttribute('aria-busy', 'false'); + }); + }); + } diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js new file mode 100644 index 000000000..d8026e8df --- /dev/null +++ b/src/ui/contextControls.js @@ -0,0 +1,74 @@ +import { _initGlobalContextPanel } from './contextBindings.js'; +import { _syncContextModeButtons } from './contextPresentation.js'; +import { _trackContextLayerReaction, _waitForContextLayerSettlement, _captureContextSessionSnapshot, _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './contextSession.js'; +import { _selectContextMode, _deactivateContextForLayerChange, _clearLayersOutsideContextMode } from './contextTransactions.js'; +import { _handleContextLayerChange } from './contextLayerChanges.js'; +import { _runUserFacingContextAction, _claimContextVisualAuthority, getContextModeState, setContextMode, clearSelectedLayers } from './contextActions.js'; +import { connectContextManager } from './contextSubscriptions.js'; + +/** Own Context transactions and controls through explicit layer and presentation ports. */ +export class ContextControls { + constructor({ elements, installations, actions }) { + Object.assign(this, elements); + this.installations = installations; + this.actions = actions; + this.listeners = new AbortController(); + this.destroyed = false; + this._dataManager = null; + this._contextMode = null; + this._contextModeChanging = false; + this._contextModeGeneration = 0; + this._contextModeEntering = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + this._contextModeDeferredEntryIntent = null; + this._contextSessionSnapshot = null; + this._contextRestoreState = null; + this._contextLayerReactionPromises = new Set(); + this._preservePanelStateDuringLayerClear = false; + this._userFacingContextNotificationTokens = new Set(); + this._clearSelectedLayersPromise = null; + this._clearSelectedLayersManagerPromise = null; + this._initGlobalContextPanel(); + } + showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); } + setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); } + get cockpitView() { return this.actions.getCockpit(); } + listen(target, type, callback, options = {}) { + target?.addEventListener(type, callback, { ...options, signal: this.listeners.signal }); + } + connect(manager) { return connectContextManager.call(this, manager); } + disconnect() { + for (const field of ['_contextManagerUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe']) { + this[field]?.(); + this[field] = null; + } + } + stop() { + if (this.destroyed) return; + this.destroyed = true; + this._contextModeGeneration++; + this.listeners.abort(); + } + async restoreForDisposal() { + this._contextModeChanging = true; + this._contextMode = null; + await this._restoreContextSession(); + } + _initGlobalContextPanel(...args) { return _initGlobalContextPanel.call(this, ...args); } + _syncContextModeButtons(...args) { return _syncContextModeButtons.call(this, ...args); } + _trackContextLayerReaction(...args) { return _trackContextLayerReaction.call(this, ...args); } + _waitForContextLayerSettlement(...args) { return _waitForContextLayerSettlement.call(this, ...args); } + _captureContextSessionSnapshot(...args) { return _captureContextSessionSnapshot.call(this, ...args); } + _restoreContextSession(...args) { return _restoreContextSession.call(this, ...args); } + _restoreContextSessionAfterLayerSettles(...args) { return _restoreContextSessionAfterLayerSettles.call(this, ...args); } + _selectContextMode(...args) { return _selectContextMode.call(this, ...args); } + _deactivateContextForLayerChange(...args) { return _deactivateContextForLayerChange.call(this, ...args); } + _clearLayersOutsideContextMode(...args) { return _clearLayersOutsideContextMode.call(this, ...args); } + _handleContextLayerChange(...args) { return _handleContextLayerChange.call(this, ...args); } + _runUserFacingContextAction(...args) { return _runUserFacingContextAction.call(this, ...args); } + _claimContextVisualAuthority(...args) { return _claimContextVisualAuthority.call(this, ...args); } + getContextModeState(...args) { return getContextModeState.call(this, ...args); } + setContextMode(...args) { return setContextMode.call(this, ...args); } + clearSelectedLayers(...args) { return clearSelectedLayers.call(this, ...args); } +} diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs new file mode 100644 index 000000000..77bbb929a --- /dev/null +++ b/src/ui/contextControls.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { ContextControls } from './contextControls.js'; + +class Button extends EventTarget { + constructor() { super(); this.attributes = new Map(); } + getAttribute(name) { return this.attributes.get(name); } + setAttribute(name, value) { this.attributes.set(name, value); } +} +const turn = () => new Promise((resolve) => setImmediate(resolve)); +function fixture(t, elements = {}) { + const calls = []; + const controls = new ContextControls({ + elements, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: () => calls.push('claim'), + showToast: () => calls.push('notice'), + setClearBusy: () => {}, + syncDetection: () => {}, + scheduleLayout: () => {}, + refreshRadio: () => {}, + setPanelCollapsed: () => calls.push('expand'), + }, + }); + t.after(() => { controls.stop(); controls.disconnect(); }); + return { controls, calls }; +} + +test('Context starts with an explicit idle snapshot', (t) => { + const { controls } = fixture(t); + assert.deepEqual(controls.getContextModeState(), { + mode: null, active: false, changing: false, entering: null, + canContact: true, canMission: true, snapshotCaptured: false, + }); +}); + +test('a pending Context tab cannot reopen a disposed panel', async (t) => { + const tab = new Button(); + const { controls, calls } = fixture(t, { _globalContextFlightsBtn: tab }); + let release; + controls._selectContextMode = () => new Promise((resolve) => { release = resolve; }); + tab.dispatchEvent(new Event('click')); + assert.deepEqual(calls, ['claim']); + controls.stop(); + release(true); + await turn(); + tab.dispatchEvent(new Event('click')); + assert.deepEqual(calls, ['claim']); +}); + +test('stopping during installations enable prevents the delayed search', async (t) => { + const button = new Button(); + const { controls, calls } = fixture(t, { _installationsSearchBtn: button }); + let release; + controls._dataManager = { + layers: new Map([['military-installations', {}]]), + setEnabled: () => new Promise((resolve) => { release = resolve; }), + isEnabled: () => true, + }; + controls.installations.searchNearby = () => assert.fail('search started after stop'); + button.dispatchEvent(new Event('click')); + controls.stop(); + release(true); + await turn(); + assert.equal(calls.length, 0); + assert.equal(button.getAttribute('aria-busy'), 'true', 'disposed controls are not mutated by completion'); +}); + +test('a late installations search cannot publish a notice after disposal', async (t) => { + const button = new Button(); + const { controls, calls } = fixture(t, { _installationsSearchBtn: button }); + let release; + controls._dataManager = { + layers: new Map([['military-installations', {}]]), + setEnabled: async () => true, isEnabled: () => true, + }; + controls.installations.searchNearby = () => new Promise((resolve) => { release = resolve; }); + button.dispatchEvent(new Event('click')); + await turn(); + controls.stop(); + release(true); + await turn(); + assert.deepEqual(calls, []); +}); + +test('reconnecting and disposal release each manager subscription exactly once', (t) => { + const { controls } = fixture(t); + const counts = new Map(); + const manager = (name) => Object.fromEntries([ + 'subscribe', 'subscribeVisibilityRequests', 'addVisibilityGuard', 'subscribeBeforeDestroy', + ].map((method) => [method, () => () => { + const key = `${name}:${method}`; + counts.set(key, (counts.get(key) || 0) + 1); + }])); + controls.connect(manager('a')); + controls.connect(manager('b')); + assert.equal(counts.size, 4); + controls.stop(); + controls.disconnect(); + controls.disconnect(); + controls.connect(manager('c')); + assert.equal(counts.size, 8); + assert.ok([...counts.values()].every((count) => count === 1)); +}); + +test('a rejected tracked reaction settles without an unhandled rejection', async (t) => { + const { controls } = fixture(t); + const failure = new Error('source unavailable'); + const pending = controls._trackContextLayerReaction(Promise.reject(failure)); + await assert.rejects(pending, failure); + await controls._waitForContextLayerSettlement(); + assert.equal(controls._contextLayerReactionPromises.size, 0); +}); diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js new file mode 100644 index 000000000..dc7003978 --- /dev/null +++ b/src/ui/contextLayerChanges.js @@ -0,0 +1,187 @@ +import { cockpitEntryAllowed, isExplicitUserIntentOrigin, recordContextRestoreExplicitChange, recordContextSessionUserChange, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition } from '../contextModePolicy.js'; + +export function _handleContextLayerChange(change) { + if (this.destroyed) return; + if (change?.layerId === 'radio' && [ + 'visibility-transition', + 'visibility', + 'visibility-cancelled', + 'visibility-failed', + ].includes(change.type)) { + this.actions.refreshRadio(); + } + if (change?.type === 'visibility-transition') return; + // The effective mode must be read BEFORE the entering flag is cleared: + // the entry layer's own enable event is the one that clears it, and the + // session bookkeeping below needs to know a mode was being entered. + const effectiveContextMode = this._contextModeEntering || this._contextMode; + if (change?.type === 'visibility-cancelled') { + const cancellationDisposition = spaceMissionEntryCancellationDisposition({ + change, + }); + if ( + this._contextModeDeferredEntryIntent?.layerId === change.layerId + && this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch + ) { + if (cancellationDisposition !== 'replacement') { + this._contextModeDeferredEntryIntent = null; + } + } + if (cancellationDisposition === 'replacement') { + this._contextModeEntering = 'space-missions'; + const entryIntent = this._contextModeEntryIntent; + if ( + entryIntent?.generation === this._contextModeGeneration + && entryIntent.layerId === change.layerId + && entryIntent.intentEpoch === change.intentEpoch + ) { + this._contextModeReplacementIntent = { + generation: entryIntent.generation, + layerId: change.layerId, + intentEpoch: change.successorIntentEpoch, + }; + } + } else if (cancellationDisposition === 'restore') { + this._contextModeEntering = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + if (this._contextSessionSnapshot && !this._contextModeChanging) { + this._contextMode = null; + void this._trackContextLayerReaction(this._runUserFacingContextAction( + async (notificationToken) => { + await this._restoreContextSessionAfterLayerSettles( + change.layerId, + { notificationToken }, + ); + return true; + }, + 'Space Missions cancellation could not restore the previous layer state', + )); + } + } + this._syncContextModeButtons(); + return; + } + if ( + change?.layerId === 'rocket-launches' + && ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type) + ) { + this._contextModeEntering = null; + } + if (change?.type === 'visibility-blocked') { + if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { + this.showToast(change.reason || 'That layer is unavailable in the current Context mode'); + } + this._syncContextModeButtons(); + return; + } + if (change?.type === 'visibility-failed') { + const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`; + // A failed direct Context-shell START has already had its siblings + // cleared by the visibility guard. Wait outside the synchronous manager + // notification for this queue to settle, then reconcile the complete + // snapshot, including an uncertain failed shell. + const needsDeferredShellRestore = ( + ['military-awareness', 'rocket-launches'].includes(change.layerId) + && change.enabled + && this._contextSessionSnapshot + && !this._contextModeChanging + ); + if (needsDeferredShellRestore) { + this._contextMode = null; + void this._trackContextLayerReaction(this._runUserFacingContextAction( + async (notificationToken) => { + await this._restoreContextSessionAfterLayerSettles( + change.layerId, + { notificationToken }, + ); + // The wrapper owns failure announcements. On a successful rollback + // announce the original activation failure here so the same direct + // action still produces exactly one accessible notification. + this.showToast(failureMessage); + return true; + }, + failureMessage, + )); + } else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { + this.showToast(failureMessage); + } + this._syncContextModeButtons(); + return; + } + if (change?.type === 'visibility-will-change') { + // Explicit entry capture happens on the synchronous visibility-requested + // boundary. Keeping this later branch side-effect free prevents an + // awaited Clear/guard from replacing that authoritative pre-entry view. + return; + } + // Session bookkeeping must run BEFORE any exit path below: the exit + // handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes + // a layer resurrected against the user's explicit disable. + recordContextSessionUserChange({ + snapshot: this._contextSessionSnapshot, + change, + effectiveContextMode, + }); + recordContextRestoreExplicitChange({ + restoreState: this._contextRestoreState, + change, + }); + if (shouldExitContextForLayerChange({ + contextMode: this._contextMode, + globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'), + change, + })) { + void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( + this._deactivateContextForLayerChange({ notificationToken }) + ))); + return; + } + if (!this._contextModeChanging) { + if (change.layerId === 'military-awareness') { + // The coordinator remains manager-addressable for restoration and + // programmatic routes, but Contacts is selected only from the + // dedicated right-side Global Context chooser. + this._contextMode = change.enabled + ? null + : (this._contextMode === 'flights' ? null : this._contextMode); + if (change.enabled) { + this._syncContextModeButtons(); + } else if (this._contextSessionSnapshot) { + void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( + this._deactivateContextForLayerChange({ notificationToken }) + ))); + } + } else if (change.layerId === 'rocket-launches') { + const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId) + || this._contextMode === 'space-missions' + || effectiveContextMode === 'space-missions'; + if (!ownsContextEntry) return; + this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode); + if (change.enabled) { + this._syncContextModeButtons(); + } else if (this._contextSessionSnapshot) { + void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( + this._deactivateContextForLayerChange({ notificationToken }) + ))); + } + } else if ( + this._contextMode === 'flights' + && ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId) + && !change.enabled + ) { + void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( + this._deactivateContextForLayerChange({ notificationToken }) + ))); + } + } + if (this.cockpitView?.active && !cockpitEntryAllowed({ + contextMode: this._contextMode, + contextModeChanging: this._contextModeChanging, + flightsEnabled: !!this._dataManager?.isEnabled('flights'), + militaryEnabled: !!this._dataManager?.isEnabled('military'), + })) { + this.cockpitView.exit({ restoreTracking: false }); + } + this._syncContextModeButtons(); + } diff --git a/src/ui/contextPresentation.js b/src/ui/contextPresentation.js new file mode 100644 index 000000000..2c078304b --- /dev/null +++ b/src/ui/contextPresentation.js @@ -0,0 +1,34 @@ + + +export function _syncContextModeButtons() { + if (this.destroyed) return; + const flightsActive = this._contextMode === 'flights'; + const missionsActive = this._contextMode === 'space-missions'; + const panel = this._globalContextPanel; + panel?.classList.toggle('context-enabled', flightsActive || missionsActive); + panel?.setAttribute('data-context-mode', this._contextMode || 'none'); + this._globalContextFlightsBtn?.classList.toggle('active', flightsActive); + this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive)); + this._globalContextMissionsBtn?.classList.toggle('active', missionsActive); + this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive)); + const transitionBusy = Boolean(this._contextModeChanging); + // Both Context choices stay in the ordinary Tab sequence. Arrow keys still + // provide tablist navigation, but must not be the only way to reach Space + // Missions from the keyboard. Semantic busy state keeps them perceivable + // while synchronous click guards prevent a second transition. + for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) { + if (!button) continue; + button.disabled = false; + button.tabIndex = 0; + button.setAttribute('aria-disabled', String(transitionBusy)); + button.setAttribute('aria-busy', String(transitionBusy)); + } + if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive; + if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive; + if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive; + this.cockpitView?.syncEntry(); + // Every _contextMode mutation funnels through here; the sync no-ops until + // the transaction settles, so this is the activation/deactivation edge. + this.actions.syncDetection(); + this.actions.scheduleLayout(); + } diff --git a/src/ui/contextSession.js b/src/ui/contextSession.js new file mode 100644 index 000000000..2b2376967 --- /dev/null +++ b/src/ui/contextSession.js @@ -0,0 +1,162 @@ +import { contextRestoreLayerIds, contextSnapshotLayerIds, mergeContextTransitionErrors, settleContextIntentReplay } from '../contextModePolicy.js'; + +export function _trackContextLayerReaction(promise) { + const tracked = Promise.resolve(promise); + this._contextLayerReactionPromises.add(tracked); + void tracked.then( + () => this._contextLayerReactionPromises.delete(tracked), + () => this._contextLayerReactionPromises.delete(tracked), + ); + return tracked; + } + +export async function _waitForContextLayerSettlement() { + while (this._contextLayerReactionPromises.size > 0) { + await Promise.allSettled([...this._contextLayerReactionPromises]); + } + } + +export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { + if (!this._dataManager || this._contextSessionSnapshot) return; + const params = {}; + for (const layerId of ['military-awareness', 'satellites']) { + const value = this._dataManager.getLayerParams(layerId); + if (value) params[layerId] = value; + } + this._contextSessionSnapshot = { + enabledLayerIds: contextSnapshotLayerIds( + this._dataManager.getEnabledLayerIds(), + this._contextRestoreState?.enabledLayerIds, + excludeLayerIds, + ), + userAdded: new Set(), + userRemoved: new Set(), + params, + }; + } + +export async function _restoreContextSession({ + excludeLayerIds = [], + notificationToken = null, + signal = null, + } = {}) { + const snapshot = this._contextSessionSnapshot; + if (!snapshot || !this._dataManager) return; + // Clear the stored session before emitting restore notifications so none + // of those transitions can be mistaken for a fresh Context entry. + this._contextSessionSnapshot = null; + const restoreState = { + enabledLayerIds: contextRestoreLayerIds(snapshot), + explicitLayerStates: new Map(), + }; + this._contextRestoreState = restoreState; + for (const [layerId, params] of Object.entries(snapshot.params)) { + this._dataManager.setLayerParams(layerId, params); + } + let restoreError = null; + const restoreSnapshot = async (restoreSignal = null) => { + // Contacts owns the dependency intents it starts. Settle that + // coordinator before restoring the remaining snapshot, otherwise its + // dependency releases can supersede the restore's same-target requests + // and make a valid Contacts-to-Missions handoff look like a failure. + const contactsCoordinatorId = 'military-awareness'; + const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId) + && this._dataManager.isEffectivelyEnabled(contactsCoordinatorId); + if (settleContactsCoordinator) { + const coordinatorSettled = await this._dataManager.setEnabled( + contactsCoordinatorId, + false, + { + origin: 'context-restore', + ...(notificationToken ? { notificationToken } : {}), + ...(restoreSignal ? { signal: restoreSignal } : {}), + }, + ); + if (coordinatorSettled === false) { + const error = new Error('Failed to settle Contacts before restoring Context'); + error.failedLayerIds = [contactsCoordinatorId]; + throw error; + } + } + await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, { + origin: 'context-restore', + excludeLayerIds: settleContactsCoordinator + ? [...excludeLayerIds, contactsCoordinatorId] + : excludeLayerIds, + notificationToken, + ...(restoreSignal ? { signal: restoreSignal } : {}), + }); + }; + try { + await restoreSnapshot(signal); + } catch (error) { + restoreError = error; + // A caller abort can arrive after only part of the exact restore has + // settled. Finish that same target without the stale caller signal while + // this restoreState still records newer explicit intents; replay below + // then gives those newer intents final authority. + if (signal?.aborted && !restoreState.cancelled) { + try { + await restoreSnapshot(null); + restoreError = null; + } catch (compensationError) { + restoreError = mergeContextTransitionErrors(restoreError, compensationError); + } + } + } finally { + if (this._contextRestoreState === restoreState) this._contextRestoreState = null; + } + // Clear Selected Layers owns a newer global OFF intent. A restore that was + // already awaiting lifecycle work must not replay its captured companion + // intent or recreate the discarded session after Clear invalidates it. + if (restoreState.cancelled) return; + // A direct Radio command may finish after restore has already copied its + // target and queued the opposite state. Replay that newer intent only + // after the stale queue drains; the replay origin cannot recurse here. + const replaySignal = signal?.aborted ? null : signal; + const replayError = await settleContextIntentReplay({ + restoreState, + setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled( + layerId, + enabled, + { ...options, ...(replaySignal ? { signal: replaySignal } : {}) }, + ), + notificationToken, + }); + restoreError = mergeContextTransitionErrors(restoreError, replayError); + if (restoreError && !this._contextSessionSnapshot) { + // Keep the exact still-pending target so a later exit/teardown can retry + // instead of silently losing the user's pre-Context layer state. + this._contextSessionSnapshot = { + enabledLayerIds: new Set(restoreState.enabledLayerIds), + userAdded: new Set(), + userRemoved: new Set(), + params: snapshot.params, + }; + } + if (restoreError) throw restoreError; + } + +/* + * Cross-mode cancellation and failure deliberately settle on Context OFF. + * + * A reinstatement transaction lived here for three review rounds and was + * removed on purpose. Restoring the prior mode is genuinely racy: the prior + * mode has to be read before the teardown, but a second request arriving + * while the first reinstatement is mid-activation reads `_contextMode` as + * null and inherits nothing, so two overlapping cancellations still land on + * OFF — and the only fix is a cross-transaction "logical prior mode" chain, + * which is new shared mutable state read while an earlier transaction is + * still awaiting. That trades a rare wrong resting state for a permanent + * interleaving hazard. + * + * The defect that started this was the LIE, not the OFF: the transition + * claimed to have cancelled cleanly while silently leaving Context off. So + * the resting state stays OFF and is REPORTED as such, with the failed layer + * ids preserved. A restore feature can be rebuilt post-launch on the + * generation discipline the surrounding transaction already follows. + */ +export async function _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) { + await this._dataManager?.waitForLayerSettled?.(layerId); + return this._restoreContextSession({ notificationToken }); + } diff --git a/src/ui/contextSubscriptions.js b/src/ui/contextSubscriptions.js new file mode 100644 index 000000000..54e5c31bb --- /dev/null +++ b/src/ui/contextSubscriptions.js @@ -0,0 +1,123 @@ +import { contextLayerEnableBlockReason, isExplicitUserIntentOrigin, runWithContextModeChanging, settleContextModeChange, shouldCaptureContextSession, shouldDeferContextEntryDuringClear } from '../contextModePolicy.js'; + +export function connectContextManager(manager) { + this.disconnect(); + if (this.destroyed) return; + this._dataManager = manager || null; + this._contextManagerUnsubscribe = this._dataManager?.subscribe?.((change) => { + if (String(change?.type || '').startsWith('visibility')) this._handleContextLayerChange(change); + }); + if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { + this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => { + if (this.destroyed) return; + if (shouldCaptureContextSession(change)) { + // This event is synchronous with intent publication, before an + // awaited guard or Clear All can alter the rest of the layer set. + // Manager effective visibility already includes both the new entry + // intent and Clear's reserved OFF baseline. + this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] }); + if (shouldDeferContextEntryDuringClear({ + change, + clearInFlight: Boolean(this._clearSelectedLayersPromise), + })) { + this._contextModeDeferredEntryIntent = { + layerId: change.layerId, + intentEpoch: change.intentEpoch, + origin: change.origin, + }; + this._contextModeEntering = 'space-missions'; + this._syncContextModeButtons(); + } + } else if ( + change?.layerId === 'rocket-launches' + && change.enabled === false + && isExplicitUserIntentOrigin(change.origin, change.layerId) + ) { + this._contextModeDeferredEntryIntent = null; + if (this._clearSelectedLayersPromise) { + this._contextSessionSnapshot = null; + this._contextModeEntering = null; + this._syncContextModeButtons(); + } + } + }); + } + if (typeof this._dataManager?.addVisibilityGuard === 'function') { + this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => { + if (this.destroyed) return null; + const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId; + const reason = contextLayerEnableBlockReason({ + contextMode: this._contextModeEntering || this._contextMode, + change, + layerName, + }); + if (reason) return reason; + if ( + change.enabled + && ['military-awareness', 'rocket-launches'].includes(change.layerId) + && shouldCaptureContextSession(change) + && ( + !this._contextModeChanging + || ( + change.layerId === 'rocket-launches' + && this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch + ) + ) + ) { + const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null; + const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch; + // A deferred entry owns the state after Clear settles. Restoring + // Clear's transient busy flag here would leave Context stuck. + const priorChanging = deferredClearEntry ? false : this._contextModeChanging; + const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry'); + const ownsNotificationToken = !change.notificationToken; + if (ownsNotificationToken) { + this._userFacingContextNotificationTokens.add(notificationToken); + } + this._contextModeEntering = entryMode; + this._contextModeChanging = true; + try { + if (deferredClearEntry) { + await this._clearSelectedLayersManagerPromise; + if (this.destroyed) return false; + if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; + this._contextModeDeferredEntryIntent = null; + } + await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); + if (this.destroyed) return false; + } catch (error) { + this._contextModeEntering = null; + console.warn(`[Context] ${change.layerId} isolation failed`, error); + try { + await this._restoreContextSession({ + excludeLayerIds: [change.layerId], + notificationToken, + }); + } catch (restoreError) { + console.warn(`[Context] ${change.layerId} rollback failed`, restoreError); + } + return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`; + } finally { + if (ownsNotificationToken) { + this._userFacingContextNotificationTokens.delete(notificationToken); + } + if (!this.destroyed) settleContextModeChange(this, priorChanging); + } + } + return null; + }); + } + if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { + this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { + if (this.destroyed || !this._contextSessionSnapshot) return; + await runWithContextModeChanging(this, async () => { + this._contextMode = null; + this.cockpitView?.exit({ restoreTracking: false }); + this._syncContextModeButtons(); + await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] }); + }); + }); + } + this._syncContextModeButtons(); +} + diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js new file mode 100644 index 000000000..09b8f711c --- /dev/null +++ b/src/ui/contextTransactions.js @@ -0,0 +1,215 @@ +import { contextAllowedLayerIds, mergeContextTransitionErrors } from '../contextModePolicy.js'; + +export async function _selectContextMode(mode, { notificationToken = null, signal = null } = {}) { + if (this.destroyed || !this._dataManager) return false; + if (this._clearSelectedLayersPromise) return false; + this._contextTransitionFailedLayerIds = []; + const generation = ++this._contextModeGeneration; + const isCurrent = () => !this.destroyed && generation === this._contextModeGeneration; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + this._contextModeChanging = true; + this._syncContextModeButtons(); + try { + if (mode !== 'flights' && this.cockpitView?.active) { + this.cockpitView.exit({ restoreTracking: false }); + } + if (!mode) { + this._contextMode = null; + this._syncContextModeButtons(); + await this._restoreContextSession({ notificationToken, signal }); + return isCurrent(); + } + // A cross-mode switch dismantles the prior mode BEFORE the new one is + // committed. If the caller aborts in that window the switch never lands, + // and the resting state is Context OFF — reported as such by + // setContextMode rather than dressed up as a clean cancellation. See the + // note above _restoreContextSessionAfterLayerSettles. + const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode); + if (crossModeSwitch) { + this._contextMode = null; + await this._restoreContextSession({ notificationToken, signal }); + if (!isCurrent()) return false; + if (signal?.aborted) return false; + } + this._captureContextSessionSnapshot(); + this._contextMode = mode; + this._syncContextModeButtons(); + // Replay isolation must settle before Space Missions starts. Contacts + // keeps its non-dependency teardown in the background so slow source + // shutdown does not delay cockpit entry. + try { + await this._clearLayersOutsideContextMode(mode, { notificationToken, signal }); + } catch (error) { + if (!isCurrent()) return false; + let transitionError = error; + console.warn(`[Context] ${mode} isolation failed`, error); + this._contextMode = null; + this._syncContextModeButtons(); + try { + await this._restoreContextSession({ notificationToken }); + } catch (restoreError) { + transitionError = mergeContextTransitionErrors(transitionError, restoreError); + this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])]; + throw transitionError; + } + this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; + return false; + } + if (!isCurrent()) return false; + // Entry is one transaction: isolation succeeded above, so a failed mode + // activation must roll the cleared layers back instead of stranding the + // user in a half-entered mode with an orphaned snapshot. + const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches'; + if (mode === 'flights') { + this._dataManager.setLayerParams('military-awareness', { passive: false }); + } + let activated = false; + let activationError = null; + let activationIntent = null; + let terminalIntentOutcome = null; + try { + activationIntent = this._dataManager._setEnabledWithIntent( + entryLayerId, + true, + { notificationToken, ...(signal ? { signal } : {}) }, + ); + this._contextModeEntryIntent = { + generation, + layerId: entryLayerId, + intentEpoch: activationIntent.intentEpoch, + }; + activated = await activationIntent.promise; + terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.( + entryLayerId, + activationIntent.intentEpoch, + ); + } catch (error) { + activationError = error; + } + if (!isCurrent()) return false; + let replacementIntent = mode === 'space-missions' + && this._contextModeReplacementIntent?.generation === generation + && this._contextModeReplacementIntent.layerId === entryLayerId + ? this._contextModeReplacementIntent + : null; + while (replacementIntent) { + const outcome = await this._dataManager._waitForVisibilityIntent?.( + entryLayerId, + replacementIntent.intentEpoch, + ); + terminalIntentOutcome = outcome; + if (!isCurrent()) return false; + const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch + && outcome.enabled === true + && outcome.succeeded === true; + if (replacementOwnsMode) { + this._contextModeEntering = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + this._syncContextModeButtons(); + return true; + } + const successorEpoch = outcome?.cancellationReason === 'superseded' + && outcome.successorEnabled === true + && Number.isInteger(outcome.successorIntentEpoch) + && outcome.successorIntentEpoch > replacementIntent.intentEpoch + ? outcome.successorIntentEpoch + : null; + replacementIntent = successorEpoch === null ? null : { + generation, + layerId: entryLayerId, + intentEpoch: successorEpoch, + }; + } + if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) { + const cancelledAndSettled = terminalIntentOutcome?.succeeded === false + && ['caller-abort', 'resource-abort', 'superseded'].includes( + terminalIntentOutcome.cancellationReason, + ); + let transitionError = null; + if (!cancelledAndSettled) { + transitionError = activationError instanceof Error + ? activationError + : new Error(`Context activation failed for: ${entryLayerId}`); + transitionError.failedLayerIds = [...new Set([ + ...(transitionError.failedLayerIds || []), + entryLayerId, + ])]; + this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds]; + console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled'); + } + this._contextMode = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + this._syncContextModeButtons(); + try { + await this._restoreContextSession({ + excludeLayerIds: [entryLayerId], + notificationToken, + }); + } catch (restoreError) { + transitionError = mergeContextTransitionErrors(transitionError, restoreError); + this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; + throw transitionError; + } + // `null` means the requested entry was cancelled and its exact rollback + // completed. The action wrapper treats that as a silent non-commit, + // while callers still require literal `true` before expanding Context. + return cancelledAndSettled ? null : false; + } + this._contextModeEntryIntent = null; + return true; + } finally { + if (isCurrent()) { + this._contextModeChanging = false; + this._syncContextModeButtons(); + } + } + } + +export async function _deactivateContextForLayerChange({ notificationToken = null } = {}) { + this._contextModeGeneration += 1; + this._contextModeChanging = true; + this._contextMode = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false }); + this._syncContextModeButtons(); + try { + await this._restoreContextSession({ notificationToken }); + } finally { + this._contextModeChanging = false; + this._syncContextModeButtons(); + } + } + +export async function _clearLayersOutsideContextMode( + mode = null, + { notificationToken = null, signal = null } = {}, + ) { + const allowed = contextAllowedLayerIds(mode); + const pending = []; + for (const [layerId] of this._dataManager.layers || []) { + // Effective visibility: a disallowed layer still mid-ENABLING must be + // isolated too, or it settles ON inside the exclusive mode. + if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) { + pending.push({ + layerId, + transition: this._dataManager.setEnabled(layerId, false, { + notificationToken, + ...(signal ? { signal } : {}), + }), + }); + } + } + const results = await Promise.all(pending.map(({ transition }) => transition)); + const failed = pending + .filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId)) + .map(({ layerId }) => layerId); + if (failed.length > 0) { + const error = new Error(`Context isolation failed for: ${failed.join(', ')}`); + error.failedLayerIds = failed; + throw error; + } + } From 3e078ef49d96fc8fc951e0aacf59cc57fac54ca2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 01:41:01 -0700 Subject: [PATCH 39/92] style: format Context component modules --- scripts/format-scope.json | 12 +- scripts/qa-ui-disposal.mjs | 30 ++- src/ui/contextActions.js | 330 +++++++++++++----------- src/ui/contextBindings.js | 156 ++++++++---- src/ui/contextControls.js | 114 +++++++-- src/ui/contextControls.test.mjs | 75 ++++-- src/ui/contextLayerChanges.js | 376 +++++++++++++++------------ src/ui/contextPresentation.js | 72 +++--- src/ui/contextSession.js | 260 ++++++++++--------- src/ui/contextSubscriptions.js | 112 +++++--- src/ui/contextTransactions.js | 439 ++++++++++++++++++-------------- 11 files changed, 1175 insertions(+), 801 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d46a8d9bb..77e87bc49 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -181,5 +181,15 @@ "src/ui/cctvControls.test.mjs", "src/ui/cctvFrames.js", "src/ui/cctvPresentation.js", - "scripts/qa-camera-controls.mjs" + "scripts/qa-camera-controls.mjs", + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextControls.test.mjs", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js" ] diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index e6a8fa3ab..6e75e8e26 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -58,12 +58,22 @@ try { counts.cctv++; return cctvUnsubscribe(); }; - const contextFields = ['_contextManagerUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe']; - const contextConnected = contextFields.every((field) => typeof ui._contextControls[field] === 'function'); + const contextFields = [ + '_contextManagerUnsubscribe', + '_dataManagerVisibilityRequestUnsubscribe', + '_dataManagerVisibilityGuardUnsubscribe', + '_dataManagerBeforeDestroyUnsubscribe', + ]; + const contextConnected = contextFields.every( + (field) => typeof ui._contextControls[field] === 'function', + ); for (const field of contextFields) { const unsubscribe = ui._contextControls[field]; counts[field] = 0; - ui._contextControls[field] = () => { counts[field]++; return unsubscribe?.(); }; + ui._contextControls[field] = () => { + counts[field]++; + return unsubscribe?.(); + }; } const resizeHandler = ui._windowResizeHandler; const removeEventListener = window.removeEventListener; @@ -86,8 +96,13 @@ try { resizeHandler && observed.includes('_commandDockTrayObserver'), ), - contextReleased: contextConnected && ui._contextControls.destroyed - && contextFields.every((field) => counts[field] === 1 && ui._contextControls[field] === null), + contextReleased: + contextConnected && + ui._contextControls.destroyed && + contextFields.every( + (field) => + counts[field] === 1 && ui._contextControls[field] === null, + ), observersReleased: observed.every( (name) => counts[name] === 1 && ui[name] === null, ), @@ -108,7 +123,10 @@ try { 'real UI has the expected live resources before disposal', result.connected, ); - check('UI disposal releases all Context subscriptions and stops its controls', result.contextReleased); + check( + 'UI disposal releases all Context subscriptions and stops its controls', + result.contextReleased, + ); check( 'UI disposal disconnects each active panel observer once', result.observersReleased, diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js index 4be137f07..6b08e5411 100644 --- a/src/ui/contextActions.js +++ b/src/ui/contextActions.js @@ -1,27 +1,30 @@ -import { settleUserFacingContextAction, contextModeWord } from '../contextModePolicy.js'; +import { + settleUserFacingContextAction, + contextModeWord, +} from '../contextModePolicy.js'; export async function _runUserFacingContextAction( - operation, - message = 'Context could not restore every layer; try again', - { falseIsFailure = true } = {}, - ) { - if (this.destroyed) return false; - const notificationToken = Symbol('user-facing-context-action'); - this._userFacingContextNotificationTokens.add(notificationToken); - try { - return await settleUserFacingContextAction({ - operation: () => operation(notificationToken), - falseIsFailure, - onFailure: (error) => { - if (this.destroyed) return; - console.warn('[Context] user-facing transition failed', error); - this.showToast(message); - }, - }); - } finally { - this._userFacingContextNotificationTokens.delete(notificationToken); - } + operation, + message = 'Context could not restore every layer; try again', + { falseIsFailure = true } = {}, +) { + if (this.destroyed) return false; + const notificationToken = Symbol('user-facing-context-action'); + this._userFacingContextNotificationTokens.add(notificationToken); + try { + return await settleUserFacingContextAction({ + operation: () => operation(notificationToken), + falseIsFailure, + onFailure: (error) => { + if (this.destroyed) return; + console.warn('[Context] user-facing transition failed', error); + this.showToast(message); + }, + }); + } finally { + this._userFacingContextNotificationTokens.delete(notificationToken); } +} /** * Claim the visual restore lane for an explicit Context transition. @@ -41,159 +44,197 @@ export async function _runUserFacingContextAction( * restore-driven ones, which must stay eligible for the shared visual state. */ export function _claimContextVisualAuthority() { - this.actions.claimVisualAuthority(); - } + this.actions.claimVisualAuthority(); +} export function getContextModeState() { - return { - mode: this._contextMode || null, - active: Boolean(this._contextMode), - changing: Boolean(this._contextModeChanging), - entering: this._contextModeEntering || null, - canContact: !this._contextMode || this._contextMode === 'flights', - canMission: !this._contextMode || this._contextMode === 'space-missions', - snapshotCaptured: Boolean(this._contextSessionSnapshot), - }; - } + return { + mode: this._contextMode || null, + active: Boolean(this._contextMode), + changing: Boolean(this._contextModeChanging), + entering: this._contextModeEntering || null, + canContact: !this._contextMode || this._contextMode === 'flights', + canMission: !this._contextMode || this._contextMode === 'space-missions', + snapshotCaptured: Boolean(this._contextSessionSnapshot), + }; +} -export async function setContextMode(mode, { +export async function setContextMode( + mode, + { notificationToken = null, signal = null, isCurrent = null, claimVisualAuthority = true, - } = {}) { - const requestIsCurrent = () => !this.destroyed && !signal?.aborted - && (typeof isCurrent !== 'function' || isCurrent()); - const cancellationResult = () => ({ - ok: false, - action: 'set_context_mode', - cancelled: true, - error: 'Context request was superseded by a newer voice turn', - ...this.getContextModeState(), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }); - if (!requestIsCurrent()) return cancellationResult(); - try { - if (!mode || mode === 'off') { - // Validated explicit transition — Context owns detection, so take the - // visual lane before a delayed shared restore can reclaim it. Internal - // Cockpit choreography opts out: it is not an operator Context request. - if (claimVisualAuthority) this._claimContextVisualAuthority(); - const result = await this._selectContextMode(null, { notificationToken, signal }); - if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult(); - const state = this.getContextModeState(); - return { - ok: result === true, - action: 'set_context_mode', - mode: state.mode, - ...state, - ...(result === true ? {} : { error: 'Context mode transition did not complete' }), - ...(this._contextTransitionFailedLayerIds?.length - ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } - : {}), - }; - } - const canonical = mode === 'contacts' ? 'flights' : mode; - if (!['flights', 'space-missions'].includes(canonical)) { - return { - ok: false, - action: 'set_context_mode', - error: `Unknown context mode: ${mode}`, - mode: this._contextMode, - ...this.getContextModeState(), - }; - } - const priorMode = this._contextMode; - // Claimed only after the mode enum validates above, so a rejected request - // takes no authority and leaves the shared visual state eligible. Internal + } = {}, +) { + const requestIsCurrent = () => + !this.destroyed && + !signal?.aborted && + (typeof isCurrent !== 'function' || isCurrent()); + const cancellationResult = () => ({ + ok: false, + action: 'set_context_mode', + cancelled: true, + error: 'Context request was superseded by a newer voice turn', + ...this.getContextModeState(), + ...(this._contextTransitionFailedLayerIds?.length + ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } + : {}), + }); + if (!requestIsCurrent()) return cancellationResult(); + try { + if (!mode || mode === 'off') { + // Validated explicit transition — Context owns detection, so take the + // visual lane before a delayed shared restore can reclaim it. Internal // Cockpit choreography opts out: it is not an operator Context request. if (claimVisualAuthority) this._claimContextVisualAuthority(); - const transitioned = await this._selectContextMode(canonical, { notificationToken, signal }); - if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult(); + const result = await this._selectContextMode(null, { + notificationToken, + signal, + }); + if (result === null || (!requestIsCurrent() && result !== true)) + return cancellationResult(); const state = this.getContextModeState(); - // A cross-mode switch tears the prior mode down before it commits, so a - // cancelled or failed switch rests on Context OFF. Say that plainly: - // reporting a bare "did not complete" while the operator's Context is - // gone is the dishonesty this whole path was fixed for. The state fields - // below carry the same verdict, so text and state cannot disagree. - const crossModeSwitchLost = transitioned !== true - && Boolean(priorMode) && priorMode !== canonical && !state.mode; return { - ok: transitioned === true, + ok: result === true, action: 'set_context_mode', mode: state.mode, ...state, - ...(transitioned === true ? {} : { - // Named in the operator's vocabulary, not the internal id: this - // string is read by the voice model, which takes 'contacts'. - error: crossModeSwitchLost - ? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off` - : 'Context mode transition did not complete', - ...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}), - }), + ...(result === true + ? {} + : { error: 'Context mode transition did not complete' }), ...(this._contextTransitionFailedLayerIds?.length ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } : {}), }; - } catch (error) { - if (!requestIsCurrent()) { - return { - ...cancellationResult(), - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), - }; - } + } + const canonical = mode === 'contacts' ? 'flights' : mode; + if (!['flights', 'space-missions'].includes(canonical)) { return { ok: false, action: 'set_context_mode', - error: error?.message || 'Context mode transition failed', - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), + error: `Unknown context mode: ${mode}`, + mode: this._contextMode, ...this.getContextModeState(), }; } + const priorMode = this._contextMode; + // Claimed only after the mode enum validates above, so a rejected request + // takes no authority and leaves the shared visual state eligible. Internal + // Cockpit choreography opts out: it is not an operator Context request. + if (claimVisualAuthority) this._claimContextVisualAuthority(); + const transitioned = await this._selectContextMode(canonical, { + notificationToken, + signal, + }); + if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) + return cancellationResult(); + const state = this.getContextModeState(); + // A cross-mode switch tears the prior mode down before it commits, so a + // cancelled or failed switch rests on Context OFF. Say that plainly: + // reporting a bare "did not complete" while the operator's Context is + // gone is the dishonesty this whole path was fixed for. The state fields + // below carry the same verdict, so text and state cannot disagree. + const crossModeSwitchLost = + transitioned !== true && + Boolean(priorMode) && + priorMode !== canonical && + !state.mode; + return { + ok: transitioned === true, + action: 'set_context_mode', + mode: state.mode, + ...state, + ...(transitioned === true + ? {} + : { + // Named in the operator's vocabulary, not the internal id: this + // string is read by the voice model, which takes 'contacts'. + error: crossModeSwitchLost + ? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off` + : 'Context mode transition did not complete', + ...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}), + }), + ...(this._contextTransitionFailedLayerIds?.length + ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } + : {}), + }; + } catch (error) { + if (!requestIsCurrent()) { + return { + ...cancellationResult(), + ...(Array.isArray(error?.failedLayerIds) + ? { failedLayerIds: [...error.failedLayerIds] } + : {}), + }; + } + return { + ok: false, + action: 'set_context_mode', + error: error?.message || 'Context mode transition failed', + ...(Array.isArray(error?.failedLayerIds) + ? { failedLayerIds: [...error.failedLayerIds] } + : {}), + ...this.getContextModeState(), + }; } +} export function clearSelectedLayers() { - if (this.destroyed) return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [], cancelled: true }); - if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise; - if (!this._dataManager?.clearSelectedLayers) { - return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [] }); - } - const generation = ++this._contextModeGeneration; - const notificationToken = Symbol('clear-selected-layers'); - if (this._contextRestoreState) this._contextRestoreState.cancelled = true; - this._contextModeChanging = true; - this._contextMode = null; - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextSessionSnapshot = null; - this._contextRestoreState = null; - this._preservePanelStateDuringLayerClear = true; - this._syncContextModeButtons(); - this._userFacingContextNotificationTokens.add(notificationToken); - this.setClearBusy(true); - - const managerOperation = this._dataManager.clearSelectedLayers({ - origin: 'user', - notificationToken, + if (this.destroyed) + return Promise.resolve({ + targetIds: [], + items: [], + clearedIds: [], + notClearedIds: [], + cancelled: true, }); - this._clearSelectedLayersManagerPromise = managerOperation; - const operation = managerOperation.then((result) => { + if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise; + if (!this._dataManager?.clearSelectedLayers) { + return Promise.resolve({ + targetIds: [], + items: [], + clearedIds: [], + notClearedIds: [], + }); + } + const generation = ++this._contextModeGeneration; + const notificationToken = Symbol('clear-selected-layers'); + if (this._contextRestoreState) this._contextRestoreState.cancelled = true; + this._contextModeChanging = true; + this._contextMode = null; + this._contextModeEntering = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + this._contextSessionSnapshot = null; + this._contextRestoreState = null; + this._preservePanelStateDuringLayerClear = true; + this._syncContextModeButtons(); + this._userFacingContextNotificationTokens.add(notificationToken); + this.setClearBusy(true); + + const managerOperation = this._dataManager.clearSelectedLayers({ + origin: 'user', + notificationToken, + }); + this._clearSelectedLayersManagerPromise = managerOperation; + const operation = managerOperation + .then((result) => { if (result.targetIds.length === 0) { this.showToast('No selected data layers'); } else if (result.notClearedIds.length > 0) { - this.showToast(`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`); + this.showToast( + `${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`, + ); } else { - this.showToast(`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`); + this.showToast( + `Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`, + ); } return result; - }).catch((error) => { + }) + .catch((error) => { console.warn('[Data] clear selected layers failed', error); this.showToast('Selected data layers could not be cleared'); return { @@ -203,7 +244,8 @@ export function clearSelectedLayers() { notClearedIds: [], error, }; - }).finally(() => { + }) + .finally(() => { this._userFacingContextNotificationTokens.delete(notificationToken); if (generation === this._contextModeGeneration) { this._contextModeChanging = false; @@ -214,6 +256,6 @@ export function clearSelectedLayers() { this._clearSelectedLayersManagerPromise = null; this._clearSelectedLayersPromise = null; }); - this._clearSelectedLayersPromise = operation; - return operation; - } + this._clearSelectedLayersPromise = operation; + return operation; +} diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js index 50b1b9f0c..5d3f074ce 100644 --- a/src/ui/contextBindings.js +++ b/src/ui/contextBindings.js @@ -1,77 +1,121 @@ import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js'; export function _initGlobalContextPanel() { - const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean); - contextTabs.forEach((tab, index) => this.listen(tab, 'keydown', (event) => { + const contextTabs = [ + this._globalContextFlightsBtn, + this._globalContextMissionsBtn, + ].filter(Boolean); + contextTabs.forEach((tab, index) => + this.listen(tab, 'keydown', (event) => { let nextIndex = null; - if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length; - else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length; + if (event.key === 'ArrowRight') + nextIndex = (index + 1) % contextTabs.length; + else if (event.key === 'ArrowLeft') + nextIndex = (index - 1 + contextTabs.length) % contextTabs.length; else if (event.key === 'Home') nextIndex = 0; else if (event.key === 'End') nextIndex = contextTabs.length - 1; if (nextIndex === null) return; event.preventDefault(); contextTabs[nextIndex].focus({ preventScroll: true }); contextTabs[nextIndex].click(); - })); - this.listen(this._globalContextFlightsBtn, 'click', () => { - if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return; - const nextMode = this._contextMode === 'flights' ? null : 'flights'; - this._claimContextVisualAuthority(); - void this._runUserFacingContextAction( - (notificationToken) => this._selectContextMode( - nextMode, - { notificationToken }, - ), - 'Contacts could not complete the requested transition; try again', - ).then((succeeded) => { - if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({ + }), + ); + this.listen(this._globalContextFlightsBtn, 'click', () => { + if ( + this.destroyed || + this._contextModeChanging || + this._clearSelectedLayersPromise + ) + return; + const nextMode = this._contextMode === 'flights' ? null : 'flights'; + this._claimContextVisualAuthority(); + void this._runUserFacingContextAction( + (notificationToken) => + this._selectContextMode(nextMode, { notificationToken }), + 'Contacts could not complete the requested transition; try again', + ).then((succeeded) => { + if ( + !this.destroyed && + nextMode && + shouldExpandGlobalContextPanel({ action: 'contacts', explicitUserAction: true, succeeded: succeeded === true, - })) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); + }) + ) + this.actions.setPanelCollapsed('global-context-panel', false, { + explicit: true, + }); }); - this.listen(this._globalContextMissionsBtn, 'click', () => { - if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return; - const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; - this._claimContextVisualAuthority(); - void this._runUserFacingContextAction( - (notificationToken) => this._selectContextMode( - nextMode, - { notificationToken }, - ), - 'Space Missions could not complete the requested transition; try again', - ).then((succeeded) => { - if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({ + }); + this.listen(this._globalContextMissionsBtn, 'click', () => { + if ( + this.destroyed || + this._contextModeChanging || + this._clearSelectedLayersPromise + ) + return; + const nextMode = + this._contextMode === 'space-missions' ? null : 'space-missions'; + this._claimContextVisualAuthority(); + void this._runUserFacingContextAction( + (notificationToken) => + this._selectContextMode(nextMode, { notificationToken }), + 'Space Missions could not complete the requested transition; try again', + ).then((succeeded) => { + if ( + !this.destroyed && + nextMode && + shouldExpandGlobalContextPanel({ action: 'space-missions', explicitUserAction: true, succeeded: succeeded === true, - })) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); + }) + ) + this.actions.setPanelCollapsed('global-context-panel', false, { + explicit: true, + }); }); - this.listen(this._installationsSearchBtn, 'click', () => { - if (this.destroyed || !this._dataManager?.layers?.has('military-installations')) return; - const button = this._installationsSearchBtn; - if (button.getAttribute('aria-busy') === 'true') return; - button.setAttribute('aria-disabled', 'true'); - button.setAttribute('aria-busy', 'true'); - void this._runUserFacingContextAction(async (notificationToken) => { - const enabled = await this._dataManager.setEnabled('military-installations', true, { + }); + this.listen(this._installationsSearchBtn, 'click', () => { + if ( + this.destroyed || + !this._dataManager?.layers?.has('military-installations') + ) + return; + const button = this._installationsSearchBtn; + if (button.getAttribute('aria-busy') === 'true') return; + button.setAttribute('aria-disabled', 'true'); + button.setAttribute('aria-busy', 'true'); + void this._runUserFacingContextAction(async (notificationToken) => { + const enabled = await this._dataManager.setEnabled( + 'military-installations', + true, + { origin: 'user', notificationToken, - }); - if (this.destroyed || enabled === false || !this._dataManager.isEnabled('military-installations')) return false; - const searched = await this.installations.searchNearby?.(); - if (this.destroyed || searched === false) return false; - const stats = this.installations.getStats?.(); - this.showToast(stats?.statusMessage || (stats?.status === 'zoom-in' - ? 'Zoom in to search mapped installations' - : 'Nearby installations refreshed')); - return true; - }, 'Nearby installations could not be refreshed; try again').finally(() => { - if (this.destroyed) return; - button.setAttribute('aria-disabled', 'false'); - button.setAttribute('aria-busy', 'false'); - }); + }, + ); + if ( + this.destroyed || + enabled === false || + !this._dataManager.isEnabled('military-installations') + ) + return false; + const searched = await this.installations.searchNearby?.(); + if (this.destroyed || searched === false) return false; + const stats = this.installations.getStats?.(); + this.showToast( + stats?.statusMessage || + (stats?.status === 'zoom-in' + ? 'Zoom in to search mapped installations' + : 'Nearby installations refreshed'), + ); + return true; + }, 'Nearby installations could not be refreshed; try again').finally(() => { + if (this.destroyed) return; + button.setAttribute('aria-disabled', 'false'); + button.setAttribute('aria-busy', 'false'); }); - } + }); +} diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index d8026e8df..1c1729992 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -1,9 +1,25 @@ import { _initGlobalContextPanel } from './contextBindings.js'; import { _syncContextModeButtons } from './contextPresentation.js'; -import { _trackContextLayerReaction, _waitForContextLayerSettlement, _captureContextSessionSnapshot, _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './contextSession.js'; -import { _selectContextMode, _deactivateContextForLayerChange, _clearLayersOutsideContextMode } from './contextTransactions.js'; +import { + _trackContextLayerReaction, + _waitForContextLayerSettlement, + _captureContextSessionSnapshot, + _restoreContextSession, + _restoreContextSessionAfterLayerSettles, +} from './contextSession.js'; +import { + _selectContextMode, + _deactivateContextForLayerChange, + _clearLayersOutsideContextMode, +} from './contextTransactions.js'; import { _handleContextLayerChange } from './contextLayerChanges.js'; -import { _runUserFacingContextAction, _claimContextVisualAuthority, getContextModeState, setContextMode, clearSelectedLayers } from './contextActions.js'; +import { + _runUserFacingContextAction, + _claimContextVisualAuthority, + getContextModeState, + setContextMode, + clearSelectedLayers, +} from './contextActions.js'; import { connectContextManager } from './contextSubscriptions.js'; /** Own Context transactions and controls through explicit layer and presentation ports. */ @@ -31,15 +47,31 @@ export class ContextControls { this._clearSelectedLayersManagerPromise = null; this._initGlobalContextPanel(); } - showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); } - setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); } - get cockpitView() { return this.actions.getCockpit(); } + showToast(...args) { + if (!this.destroyed) this.actions.showToast(...args); + } + setClearBusy(...args) { + if (!this.destroyed) this.actions.setClearBusy(...args); + } + get cockpitView() { + return this.actions.getCockpit(); + } listen(target, type, callback, options = {}) { - target?.addEventListener(type, callback, { ...options, signal: this.listeners.signal }); + target?.addEventListener(type, callback, { + ...options, + signal: this.listeners.signal, + }); + } + connect(manager) { + return connectContextManager.call(this, manager); } - connect(manager) { return connectContextManager.call(this, manager); } disconnect() { - for (const field of ['_contextManagerUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe']) { + for (const field of [ + '_contextManagerUnsubscribe', + '_dataManagerBeforeDestroyUnsubscribe', + '_dataManagerVisibilityGuardUnsubscribe', + '_dataManagerVisibilityRequestUnsubscribe', + ]) { this[field]?.(); this[field] = null; } @@ -55,20 +87,52 @@ export class ContextControls { this._contextMode = null; await this._restoreContextSession(); } - _initGlobalContextPanel(...args) { return _initGlobalContextPanel.call(this, ...args); } - _syncContextModeButtons(...args) { return _syncContextModeButtons.call(this, ...args); } - _trackContextLayerReaction(...args) { return _trackContextLayerReaction.call(this, ...args); } - _waitForContextLayerSettlement(...args) { return _waitForContextLayerSettlement.call(this, ...args); } - _captureContextSessionSnapshot(...args) { return _captureContextSessionSnapshot.call(this, ...args); } - _restoreContextSession(...args) { return _restoreContextSession.call(this, ...args); } - _restoreContextSessionAfterLayerSettles(...args) { return _restoreContextSessionAfterLayerSettles.call(this, ...args); } - _selectContextMode(...args) { return _selectContextMode.call(this, ...args); } - _deactivateContextForLayerChange(...args) { return _deactivateContextForLayerChange.call(this, ...args); } - _clearLayersOutsideContextMode(...args) { return _clearLayersOutsideContextMode.call(this, ...args); } - _handleContextLayerChange(...args) { return _handleContextLayerChange.call(this, ...args); } - _runUserFacingContextAction(...args) { return _runUserFacingContextAction.call(this, ...args); } - _claimContextVisualAuthority(...args) { return _claimContextVisualAuthority.call(this, ...args); } - getContextModeState(...args) { return getContextModeState.call(this, ...args); } - setContextMode(...args) { return setContextMode.call(this, ...args); } - clearSelectedLayers(...args) { return clearSelectedLayers.call(this, ...args); } + _initGlobalContextPanel(...args) { + return _initGlobalContextPanel.call(this, ...args); + } + _syncContextModeButtons(...args) { + return _syncContextModeButtons.call(this, ...args); + } + _trackContextLayerReaction(...args) { + return _trackContextLayerReaction.call(this, ...args); + } + _waitForContextLayerSettlement(...args) { + return _waitForContextLayerSettlement.call(this, ...args); + } + _captureContextSessionSnapshot(...args) { + return _captureContextSessionSnapshot.call(this, ...args); + } + _restoreContextSession(...args) { + return _restoreContextSession.call(this, ...args); + } + _restoreContextSessionAfterLayerSettles(...args) { + return _restoreContextSessionAfterLayerSettles.call(this, ...args); + } + _selectContextMode(...args) { + return _selectContextMode.call(this, ...args); + } + _deactivateContextForLayerChange(...args) { + return _deactivateContextForLayerChange.call(this, ...args); + } + _clearLayersOutsideContextMode(...args) { + return _clearLayersOutsideContextMode.call(this, ...args); + } + _handleContextLayerChange(...args) { + return _handleContextLayerChange.call(this, ...args); + } + _runUserFacingContextAction(...args) { + return _runUserFacingContextAction.call(this, ...args); + } + _claimContextVisualAuthority(...args) { + return _claimContextVisualAuthority.call(this, ...args); + } + getContextModeState(...args) { + return getContextModeState.call(this, ...args); + } + setContextMode(...args) { + return setContextMode.call(this, ...args); + } + clearSelectedLayers(...args) { + return clearSelectedLayers.call(this, ...args); + } } diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs index 77bbb929a..306c17f20 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -3,9 +3,16 @@ import { test } from 'node:test'; import { ContextControls } from './contextControls.js'; class Button extends EventTarget { - constructor() { super(); this.attributes = new Map(); } - getAttribute(name) { return this.attributes.get(name); } - setAttribute(name, value) { this.attributes.set(name, value); } + constructor() { + super(); + this.attributes = new Map(); + } + getAttribute(name) { + return this.attributes.get(name); + } + setAttribute(name, value) { + this.attributes.set(name, value); + } } const turn = () => new Promise((resolve) => setImmediate(resolve)); function fixture(t, elements = {}) { @@ -24,15 +31,23 @@ function fixture(t, elements = {}) { setPanelCollapsed: () => calls.push('expand'), }, }); - t.after(() => { controls.stop(); controls.disconnect(); }); + t.after(() => { + controls.stop(); + controls.disconnect(); + }); return { controls, calls }; } test('Context starts with an explicit idle snapshot', (t) => { const { controls } = fixture(t); assert.deepEqual(controls.getContextModeState(), { - mode: null, active: false, changing: false, entering: null, - canContact: true, canMission: true, snapshotCaptured: false, + mode: null, + active: false, + changing: false, + entering: null, + canContact: true, + canMission: true, + snapshotCaptured: false, }); }); @@ -40,7 +55,10 @@ test('a pending Context tab cannot reopen a disposed panel', async (t) => { const tab = new Button(); const { controls, calls } = fixture(t, { _globalContextFlightsBtn: tab }); let release; - controls._selectContextMode = () => new Promise((resolve) => { release = resolve; }); + controls._selectContextMode = () => + new Promise((resolve) => { + release = resolve; + }); tab.dispatchEvent(new Event('click')); assert.deepEqual(calls, ['claim']); controls.stop(); @@ -56,16 +74,24 @@ test('stopping during installations enable prevents the delayed search', async ( let release; controls._dataManager = { layers: new Map([['military-installations', {}]]), - setEnabled: () => new Promise((resolve) => { release = resolve; }), + setEnabled: () => + new Promise((resolve) => { + release = resolve; + }), isEnabled: () => true, }; - controls.installations.searchNearby = () => assert.fail('search started after stop'); + controls.installations.searchNearby = () => + assert.fail('search started after stop'); button.dispatchEvent(new Event('click')); controls.stop(); release(true); await turn(); assert.equal(calls.length, 0); - assert.equal(button.getAttribute('aria-busy'), 'true', 'disposed controls are not mutated by completion'); + assert.equal( + button.getAttribute('aria-busy'), + 'true', + 'disposed controls are not mutated by completion', + ); }); test('a late installations search cannot publish a notice after disposal', async (t) => { @@ -74,9 +100,13 @@ test('a late installations search cannot publish a notice after disposal', async let release; controls._dataManager = { layers: new Map([['military-installations', {}]]), - setEnabled: async () => true, isEnabled: () => true, + setEnabled: async () => true, + isEnabled: () => true, }; - controls.installations.searchNearby = () => new Promise((resolve) => { release = resolve; }); + controls.installations.searchNearby = () => + new Promise((resolve) => { + release = resolve; + }); button.dispatchEvent(new Event('click')); await turn(); controls.stop(); @@ -88,12 +118,21 @@ test('a late installations search cannot publish a notice after disposal', async test('reconnecting and disposal release each manager subscription exactly once', (t) => { const { controls } = fixture(t); const counts = new Map(); - const manager = (name) => Object.fromEntries([ - 'subscribe', 'subscribeVisibilityRequests', 'addVisibilityGuard', 'subscribeBeforeDestroy', - ].map((method) => [method, () => () => { - const key = `${name}:${method}`; - counts.set(key, (counts.get(key) || 0) + 1); - }])); + const manager = (name) => + Object.fromEntries( + [ + 'subscribe', + 'subscribeVisibilityRequests', + 'addVisibilityGuard', + 'subscribeBeforeDestroy', + ].map((method) => [ + method, + () => () => { + const key = `${name}:${method}`; + counts.set(key, (counts.get(key) || 0) + 1); + }, + ]), + ); controls.connect(manager('a')); controls.connect(manager('b')); assert.equal(counts.size, 4); diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index dc7003978..53b2ba760 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -1,187 +1,227 @@ -import { cockpitEntryAllowed, isExplicitUserIntentOrigin, recordContextRestoreExplicitChange, recordContextSessionUserChange, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition } from '../contextModePolicy.js'; +import { + cockpitEntryAllowed, + isExplicitUserIntentOrigin, + recordContextRestoreExplicitChange, + recordContextSessionUserChange, + shouldExitContextForLayerChange, + spaceMissionEntryCancellationDisposition, +} from '../contextModePolicy.js'; export function _handleContextLayerChange(change) { if (this.destroyed) return; - if (change?.layerId === 'radio' && [ + if ( + change?.layerId === 'radio' && + [ 'visibility-transition', 'visibility', 'visibility-cancelled', 'visibility-failed', - ].includes(change.type)) { - this.actions.refreshRadio(); - } - if (change?.type === 'visibility-transition') return; - // The effective mode must be read BEFORE the entering flag is cleared: - // the entry layer's own enable event is the one that clears it, and the - // session bookkeeping below needs to know a mode was being entered. - const effectiveContextMode = this._contextModeEntering || this._contextMode; - if (change?.type === 'visibility-cancelled') { - const cancellationDisposition = spaceMissionEntryCancellationDisposition({ - change, - }); - if ( - this._contextModeDeferredEntryIntent?.layerId === change.layerId - && this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch - ) { - if (cancellationDisposition !== 'replacement') { - this._contextModeDeferredEntryIntent = null; - } - } - if (cancellationDisposition === 'replacement') { - this._contextModeEntering = 'space-missions'; - const entryIntent = this._contextModeEntryIntent; - if ( - entryIntent?.generation === this._contextModeGeneration - && entryIntent.layerId === change.layerId - && entryIntent.intentEpoch === change.intentEpoch - ) { - this._contextModeReplacementIntent = { - generation: entryIntent.generation, - layerId: change.layerId, - intentEpoch: change.successorIntentEpoch, - }; - } - } else if (cancellationDisposition === 'restore') { - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - if (this._contextSessionSnapshot && !this._contextModeChanging) { - this._contextMode = null; - void this._trackContextLayerReaction(this._runUserFacingContextAction( - async (notificationToken) => { - await this._restoreContextSessionAfterLayerSettles( - change.layerId, - { notificationToken }, - ); - return true; - }, - 'Space Missions cancellation could not restore the previous layer state', - )); - } - } - this._syncContextModeButtons(); - return; - } + ].includes(change.type) + ) { + this.actions.refreshRadio(); + } + if (change?.type === 'visibility-transition') return; + // The effective mode must be read BEFORE the entering flag is cleared: + // the entry layer's own enable event is the one that clears it, and the + // session bookkeeping below needs to know a mode was being entered. + const effectiveContextMode = this._contextModeEntering || this._contextMode; + if (change?.type === 'visibility-cancelled') { + const cancellationDisposition = spaceMissionEntryCancellationDisposition({ + change, + }); if ( - change?.layerId === 'rocket-launches' - && ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type) + this._contextModeDeferredEntryIntent?.layerId === change.layerId && + this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch ) { - this._contextModeEntering = null; - } - if (change?.type === 'visibility-blocked') { - if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { - this.showToast(change.reason || 'That layer is unavailable in the current Context mode'); + if (cancellationDisposition !== 'replacement') { + this._contextModeDeferredEntryIntent = null; } - this._syncContextModeButtons(); - return; } - if (change?.type === 'visibility-failed') { - const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`; - // A failed direct Context-shell START has already had its siblings - // cleared by the visibility guard. Wait outside the synchronous manager - // notification for this queue to settle, then reconcile the complete - // snapshot, including an uncertain failed shell. - const needsDeferredShellRestore = ( - ['military-awareness', 'rocket-launches'].includes(change.layerId) - && change.enabled - && this._contextSessionSnapshot - && !this._contextModeChanging - ); - if (needsDeferredShellRestore) { - this._contextMode = null; - void this._trackContextLayerReaction(this._runUserFacingContextAction( - async (notificationToken) => { - await this._restoreContextSessionAfterLayerSettles( - change.layerId, - { notificationToken }, - ); - // The wrapper owns failure announcements. On a successful rollback - // announce the original activation failure here so the same direct - // action still produces exactly one accessible notification. - this.showToast(failureMessage); - return true; - }, - failureMessage, - )); - } else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { - this.showToast(failureMessage); - } - this._syncContextModeButtons(); - return; - } - if (change?.type === 'visibility-will-change') { - // Explicit entry capture happens on the synchronous visibility-requested - // boundary. Keeping this later branch side-effect free prevents an - // awaited Clear/guard from replacing that authoritative pre-entry view. - return; - } - // Session bookkeeping must run BEFORE any exit path below: the exit - // handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes - // a layer resurrected against the user's explicit disable. - recordContextSessionUserChange({ - snapshot: this._contextSessionSnapshot, - change, - effectiveContextMode, - }); - recordContextRestoreExplicitChange({ - restoreState: this._contextRestoreState, - change, - }); - if (shouldExitContextForLayerChange({ - contextMode: this._contextMode, - globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'), - change, - })) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - return; - } - if (!this._contextModeChanging) { - if (change.layerId === 'military-awareness') { - // The coordinator remains manager-addressable for restoration and - // programmatic routes, but Contacts is selected only from the - // dedicated right-side Global Context chooser. - this._contextMode = change.enabled - ? null - : (this._contextMode === 'flights' ? null : this._contextMode); - if (change.enabled) { - this._syncContextModeButtons(); - } else if (this._contextSessionSnapshot) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } else if (change.layerId === 'rocket-launches') { - const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId) - || this._contextMode === 'space-missions' - || effectiveContextMode === 'space-missions'; - if (!ownsContextEntry) return; - this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode); - if (change.enabled) { - this._syncContextModeButtons(); - } else if (this._contextSessionSnapshot) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); - } - } else if ( - this._contextMode === 'flights' - && ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId) - && !change.enabled + if (cancellationDisposition === 'replacement') { + this._contextModeEntering = 'space-missions'; + const entryIntent = this._contextModeEntryIntent; + if ( + entryIntent?.generation === this._contextModeGeneration && + entryIntent.layerId === change.layerId && + entryIntent.intentEpoch === change.intentEpoch ) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); + this._contextModeReplacementIntent = { + generation: entryIntent.generation, + layerId: change.layerId, + intentEpoch: change.successorIntentEpoch, + }; + } + } else if (cancellationDisposition === 'restore') { + this._contextModeEntering = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + if (this._contextSessionSnapshot && !this._contextModeChanging) { + this._contextMode = null; + void this._trackContextLayerReaction( + this._runUserFacingContextAction(async (notificationToken) => { + await this._restoreContextSessionAfterLayerSettles(change.layerId, { + notificationToken, + }); + return true; + }, 'Space Missions cancellation could not restore the previous layer state'), + ); } } - if (this.cockpitView?.active && !cockpitEntryAllowed({ + this._syncContextModeButtons(); + return; + } + if ( + change?.layerId === 'rocket-launches' && + ['visibility', 'visibility-blocked', 'visibility-failed'].includes( + change.type, + ) + ) { + this._contextModeEntering = null; + } + if (change?.type === 'visibility-blocked') { + if ( + !this._userFacingContextNotificationTokens.has(change.notificationToken) + ) { + this.showToast( + change.reason || + 'That layer is unavailable in the current Context mode', + ); + } + this._syncContextModeButtons(); + return; + } + if (change?.type === 'visibility-failed') { + const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`; + // A failed direct Context-shell START has already had its siblings + // cleared by the visibility guard. Wait outside the synchronous manager + // notification for this queue to settle, then reconcile the complete + // snapshot, including an uncertain failed shell. + const needsDeferredShellRestore = + ['military-awareness', 'rocket-launches'].includes(change.layerId) && + change.enabled && + this._contextSessionSnapshot && + !this._contextModeChanging; + if (needsDeferredShellRestore) { + this._contextMode = null; + void this._trackContextLayerReaction( + this._runUserFacingContextAction(async (notificationToken) => { + await this._restoreContextSessionAfterLayerSettles(change.layerId, { + notificationToken, + }); + // The wrapper owns failure announcements. On a successful rollback + // announce the original activation failure here so the same direct + // action still produces exactly one accessible notification. + this.showToast(failureMessage); + return true; + }, failureMessage), + ); + } else if ( + !this._userFacingContextNotificationTokens.has(change.notificationToken) + ) { + this.showToast(failureMessage); + } + this._syncContextModeButtons(); + return; + } + if (change?.type === 'visibility-will-change') { + // Explicit entry capture happens on the synchronous visibility-requested + // boundary. Keeping this later branch side-effect free prevents an + // awaited Clear/guard from replacing that authoritative pre-entry view. + return; + } + // Session bookkeeping must run BEFORE any exit path below: the exit + // handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes + // a layer resurrected against the user's explicit disable. + recordContextSessionUserChange({ + snapshot: this._contextSessionSnapshot, + change, + effectiveContextMode, + }); + recordContextRestoreExplicitChange({ + restoreState: this._contextRestoreState, + change, + }); + if ( + shouldExitContextForLayerChange({ + contextMode: this._contextMode, + globalContextEnabled: + !!this._dataManager?.isEnabled('military-awareness'), + change, + }) + ) { + void this._trackContextLayerReaction( + this._runUserFacingContextAction((notificationToken) => + this._deactivateContextForLayerChange({ notificationToken }), + ), + ); + return; + } + if (!this._contextModeChanging) { + if (change.layerId === 'military-awareness') { + // The coordinator remains manager-addressable for restoration and + // programmatic routes, but Contacts is selected only from the + // dedicated right-side Global Context chooser. + this._contextMode = change.enabled + ? null + : this._contextMode === 'flights' + ? null + : this._contextMode; + if (change.enabled) { + this._syncContextModeButtons(); + } else if (this._contextSessionSnapshot) { + void this._trackContextLayerReaction( + this._runUserFacingContextAction((notificationToken) => + this._deactivateContextForLayerChange({ notificationToken }), + ), + ); + } + } else if (change.layerId === 'rocket-launches') { + const ownsContextEntry = + isExplicitUserIntentOrigin(change.origin, change.layerId) || + this._contextMode === 'space-missions' || + effectiveContextMode === 'space-missions'; + if (!ownsContextEntry) return; + this._contextMode = change.enabled + ? 'space-missions' + : this._contextMode === 'space-missions' + ? null + : this._contextMode; + if (change.enabled) { + this._syncContextModeButtons(); + } else if (this._contextSessionSnapshot) { + void this._trackContextLayerReaction( + this._runUserFacingContextAction((notificationToken) => + this._deactivateContextForLayerChange({ notificationToken }), + ), + ); + } + } else if ( + this._contextMode === 'flights' && + [ + 'flights', + 'military', + 'ais-live-vessels', + 'military-installations', + ].includes(change.layerId) && + !change.enabled + ) { + void this._trackContextLayerReaction( + this._runUserFacingContextAction((notificationToken) => + this._deactivateContextForLayerChange({ notificationToken }), + ), + ); + } + } + if ( + this.cockpitView?.active && + !cockpitEntryAllowed({ contextMode: this._contextMode, contextModeChanging: this._contextModeChanging, flightsEnabled: !!this._dataManager?.isEnabled('flights'), militaryEnabled: !!this._dataManager?.isEnabled('military'), - })) { - this.cockpitView.exit({ restoreTracking: false }); - } - this._syncContextModeButtons(); + }) + ) { + this.cockpitView.exit({ restoreTracking: false }); } + this._syncContextModeButtons(); +} diff --git a/src/ui/contextPresentation.js b/src/ui/contextPresentation.js index 2c078304b..d3b45cf18 100644 --- a/src/ui/contextPresentation.js +++ b/src/ui/contextPresentation.js @@ -1,34 +1,44 @@ - - export function _syncContextModeButtons() { if (this.destroyed) return; - const flightsActive = this._contextMode === 'flights'; - const missionsActive = this._contextMode === 'space-missions'; - const panel = this._globalContextPanel; - panel?.classList.toggle('context-enabled', flightsActive || missionsActive); - panel?.setAttribute('data-context-mode', this._contextMode || 'none'); - this._globalContextFlightsBtn?.classList.toggle('active', flightsActive); - this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive)); - this._globalContextMissionsBtn?.classList.toggle('active', missionsActive); - this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive)); - const transitionBusy = Boolean(this._contextModeChanging); - // Both Context choices stay in the ordinary Tab sequence. Arrow keys still - // provide tablist navigation, but must not be the only way to reach Space - // Missions from the keyboard. Semantic busy state keeps them perceivable - // while synchronous click guards prevent a second transition. - for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) { - if (!button) continue; - button.disabled = false; - button.tabIndex = 0; - button.setAttribute('aria-disabled', String(transitionBusy)); - button.setAttribute('aria-busy', String(transitionBusy)); - } - if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive; - if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive; - if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive; - this.cockpitView?.syncEntry(); - // Every _contextMode mutation funnels through here; the sync no-ops until - // the transaction settles, so this is the activation/deactivation edge. - this.actions.syncDetection(); - this.actions.scheduleLayout(); + const flightsActive = this._contextMode === 'flights'; + const missionsActive = this._contextMode === 'space-missions'; + const panel = this._globalContextPanel; + panel?.classList.toggle('context-enabled', flightsActive || missionsActive); + panel?.setAttribute('data-context-mode', this._contextMode || 'none'); + this._globalContextFlightsBtn?.classList.toggle('active', flightsActive); + this._globalContextFlightsBtn?.setAttribute( + 'aria-selected', + String(flightsActive), + ); + this._globalContextMissionsBtn?.classList.toggle('active', missionsActive); + this._globalContextMissionsBtn?.setAttribute( + 'aria-selected', + String(missionsActive), + ); + const transitionBusy = Boolean(this._contextModeChanging); + // Both Context choices stay in the ordinary Tab sequence. Arrow keys still + // provide tablist navigation, but must not be the only way to reach Space + // Missions from the keyboard. Semantic busy state keeps them perceivable + // while synchronous click guards prevent a second transition. + for (const button of [ + this._globalContextFlightsBtn, + this._globalContextMissionsBtn, + ]) { + if (!button) continue; + button.disabled = false; + button.tabIndex = 0; + button.setAttribute('aria-disabled', String(transitionBusy)); + button.setAttribute('aria-busy', String(transitionBusy)); } + if (this._contextModeStandby) + this._contextModeStandby.hidden = flightsActive || missionsActive; + if (this._contextFlightsView) + this._contextFlightsView.hidden = !flightsActive; + if (this._contextMissionsView) + this._contextMissionsView.hidden = !missionsActive; + this.cockpitView?.syncEntry(); + // Every _contextMode mutation funnels through here; the sync no-ops until + // the transaction settles, so this is the activation/deactivation edge. + this.actions.syncDetection(); + this.actions.scheduleLayout(); +} diff --git a/src/ui/contextSession.js b/src/ui/contextSession.js index 2b2376967..241b5f168 100644 --- a/src/ui/contextSession.js +++ b/src/ui/contextSession.js @@ -1,141 +1,156 @@ -import { contextRestoreLayerIds, contextSnapshotLayerIds, mergeContextTransitionErrors, settleContextIntentReplay } from '../contextModePolicy.js'; +import { + contextRestoreLayerIds, + contextSnapshotLayerIds, + mergeContextTransitionErrors, + settleContextIntentReplay, +} from '../contextModePolicy.js'; export function _trackContextLayerReaction(promise) { - const tracked = Promise.resolve(promise); - this._contextLayerReactionPromises.add(tracked); - void tracked.then( - () => this._contextLayerReactionPromises.delete(tracked), - () => this._contextLayerReactionPromises.delete(tracked), - ); - return tracked; - } + const tracked = Promise.resolve(promise); + this._contextLayerReactionPromises.add(tracked); + void tracked.then( + () => this._contextLayerReactionPromises.delete(tracked), + () => this._contextLayerReactionPromises.delete(tracked), + ); + return tracked; +} export async function _waitForContextLayerSettlement() { - while (this._contextLayerReactionPromises.size > 0) { - await Promise.allSettled([...this._contextLayerReactionPromises]); - } + while (this._contextLayerReactionPromises.size > 0) { + await Promise.allSettled([...this._contextLayerReactionPromises]); } +} export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { - if (!this._dataManager || this._contextSessionSnapshot) return; - const params = {}; - for (const layerId of ['military-awareness', 'satellites']) { - const value = this._dataManager.getLayerParams(layerId); - if (value) params[layerId] = value; - } - this._contextSessionSnapshot = { - enabledLayerIds: contextSnapshotLayerIds( - this._dataManager.getEnabledLayerIds(), - this._contextRestoreState?.enabledLayerIds, - excludeLayerIds, - ), - userAdded: new Set(), - userRemoved: new Set(), - params, - }; + if (!this._dataManager || this._contextSessionSnapshot) return; + const params = {}; + for (const layerId of ['military-awareness', 'satellites']) { + const value = this._dataManager.getLayerParams(layerId); + if (value) params[layerId] = value; } + this._contextSessionSnapshot = { + enabledLayerIds: contextSnapshotLayerIds( + this._dataManager.getEnabledLayerIds(), + this._contextRestoreState?.enabledLayerIds, + excludeLayerIds, + ), + userAdded: new Set(), + userRemoved: new Set(), + params, + }; +} export async function _restoreContextSession({ - excludeLayerIds = [], - notificationToken = null, - signal = null, - } = {}) { - const snapshot = this._contextSessionSnapshot; - if (!snapshot || !this._dataManager) return; - // Clear the stored session before emitting restore notifications so none - // of those transitions can be mistaken for a fresh Context entry. - this._contextSessionSnapshot = null; - const restoreState = { - enabledLayerIds: contextRestoreLayerIds(snapshot), - explicitLayerStates: new Map(), - }; - this._contextRestoreState = restoreState; - for (const [layerId, params] of Object.entries(snapshot.params)) { - this._dataManager.setLayerParams(layerId, params); - } - let restoreError = null; - const restoreSnapshot = async (restoreSignal = null) => { - // Contacts owns the dependency intents it starts. Settle that - // coordinator before restoring the remaining snapshot, otherwise its - // dependency releases can supersede the restore's same-target requests - // and make a valid Contacts-to-Missions handoff look like a failure. - const contactsCoordinatorId = 'military-awareness'; - const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId) - && this._dataManager.isEffectivelyEnabled(contactsCoordinatorId); - if (settleContactsCoordinator) { - const coordinatorSettled = await this._dataManager.setEnabled( - contactsCoordinatorId, - false, - { - origin: 'context-restore', - ...(notificationToken ? { notificationToken } : {}), - ...(restoreSignal ? { signal: restoreSignal } : {}), - }, + excludeLayerIds = [], + notificationToken = null, + signal = null, +} = {}) { + const snapshot = this._contextSessionSnapshot; + if (!snapshot || !this._dataManager) return; + // Clear the stored session before emitting restore notifications so none + // of those transitions can be mistaken for a fresh Context entry. + this._contextSessionSnapshot = null; + const restoreState = { + enabledLayerIds: contextRestoreLayerIds(snapshot), + explicitLayerStates: new Map(), + }; + this._contextRestoreState = restoreState; + for (const [layerId, params] of Object.entries(snapshot.params)) { + this._dataManager.setLayerParams(layerId, params); + } + let restoreError = null; + const restoreSnapshot = async (restoreSignal = null) => { + // Contacts owns the dependency intents it starts. Settle that + // coordinator before restoring the remaining snapshot, otherwise its + // dependency releases can supersede the restore's same-target requests + // and make a valid Contacts-to-Missions handoff look like a failure. + const contactsCoordinatorId = 'military-awareness'; + const settleContactsCoordinator = + !restoreState.enabledLayerIds.has(contactsCoordinatorId) && + this._dataManager.isEffectivelyEnabled(contactsCoordinatorId); + if (settleContactsCoordinator) { + const coordinatorSettled = await this._dataManager.setEnabled( + contactsCoordinatorId, + false, + { + origin: 'context-restore', + ...(notificationToken ? { notificationToken } : {}), + ...(restoreSignal ? { signal: restoreSignal } : {}), + }, + ); + if (coordinatorSettled === false) { + const error = new Error( + 'Failed to settle Contacts before restoring Context', ); - if (coordinatorSettled === false) { - const error = new Error('Failed to settle Contacts before restoring Context'); - error.failedLayerIds = [contactsCoordinatorId]; - throw error; - } + error.failedLayerIds = [contactsCoordinatorId]; + throw error; } - await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, { + } + await this._dataManager.restoreEnabledLayerIds( + restoreState.enabledLayerIds, + { origin: 'context-restore', excludeLayerIds: settleContactsCoordinator ? [...excludeLayerIds, contactsCoordinatorId] : excludeLayerIds, notificationToken, ...(restoreSignal ? { signal: restoreSignal } : {}), - }); - }; - try { - await restoreSnapshot(signal); - } catch (error) { - restoreError = error; - // A caller abort can arrive after only part of the exact restore has - // settled. Finish that same target without the stale caller signal while - // this restoreState still records newer explicit intents; replay below - // then gives those newer intents final authority. - if (signal?.aborted && !restoreState.cancelled) { - try { - await restoreSnapshot(null); - restoreError = null; - } catch (compensationError) { - restoreError = mergeContextTransitionErrors(restoreError, compensationError); - } + }, + ); + }; + try { + await restoreSnapshot(signal); + } catch (error) { + restoreError = error; + // A caller abort can arrive after only part of the exact restore has + // settled. Finish that same target without the stale caller signal while + // this restoreState still records newer explicit intents; replay below + // then gives those newer intents final authority. + if (signal?.aborted && !restoreState.cancelled) { + try { + await restoreSnapshot(null); + restoreError = null; + } catch (compensationError) { + restoreError = mergeContextTransitionErrors( + restoreError, + compensationError, + ); } - } finally { - if (this._contextRestoreState === restoreState) this._contextRestoreState = null; } - // Clear Selected Layers owns a newer global OFF intent. A restore that was - // already awaiting lifecycle work must not replay its captured companion - // intent or recreate the discarded session after Clear invalidates it. - if (restoreState.cancelled) return; - // A direct Radio command may finish after restore has already copied its - // target and queued the opposite state. Replay that newer intent only - // after the stale queue drains; the replay origin cannot recurse here. - const replaySignal = signal?.aborted ? null : signal; - const replayError = await settleContextIntentReplay({ - restoreState, - setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled( - layerId, - enabled, - { ...options, ...(replaySignal ? { signal: replaySignal } : {}) }, - ), - notificationToken, - }); - restoreError = mergeContextTransitionErrors(restoreError, replayError); - if (restoreError && !this._contextSessionSnapshot) { - // Keep the exact still-pending target so a later exit/teardown can retry - // instead of silently losing the user's pre-Context layer state. - this._contextSessionSnapshot = { - enabledLayerIds: new Set(restoreState.enabledLayerIds), - userAdded: new Set(), - userRemoved: new Set(), - params: snapshot.params, - }; - } - if (restoreError) throw restoreError; + } finally { + if (this._contextRestoreState === restoreState) + this._contextRestoreState = null; } + // Clear Selected Layers owns a newer global OFF intent. A restore that was + // already awaiting lifecycle work must not replay its captured companion + // intent or recreate the discarded session after Clear invalidates it. + if (restoreState.cancelled) return; + // A direct Radio command may finish after restore has already copied its + // target and queued the opposite state. Replay that newer intent only + // after the stale queue drains; the replay origin cannot recurse here. + const replaySignal = signal?.aborted ? null : signal; + const replayError = await settleContextIntentReplay({ + restoreState, + setEnabled: (layerId, enabled, options = {}) => + this._dataManager.setEnabled(layerId, enabled, { + ...options, + ...(replaySignal ? { signal: replaySignal } : {}), + }), + notificationToken, + }); + restoreError = mergeContextTransitionErrors(restoreError, replayError); + if (restoreError && !this._contextSessionSnapshot) { + // Keep the exact still-pending target so a later exit/teardown can retry + // instead of silently losing the user's pre-Context layer state. + this._contextSessionSnapshot = { + enabledLayerIds: new Set(restoreState.enabledLayerIds), + userAdded: new Set(), + userRemoved: new Set(), + params: snapshot.params, + }; + } + if (restoreError) throw restoreError; +} /* * Cross-mode cancellation and failure deliberately settle on Context OFF. @@ -156,7 +171,10 @@ export async function _restoreContextSession({ * ids preserved. A restore feature can be rebuilt post-launch on the * generation discipline the surrounding transaction already follows. */ -export async function _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) { - await this._dataManager?.waitForLayerSettled?.(layerId); - return this._restoreContextSession({ notificationToken }); - } +export async function _restoreContextSessionAfterLayerSettles( + layerId, + { notificationToken = null } = {}, +) { + await this._dataManager?.waitForLayerSettled?.(layerId); + return this._restoreContextSession({ notificationToken }); +} diff --git a/src/ui/contextSubscriptions.js b/src/ui/contextSubscriptions.js index 54e5c31bb..1c067e9b5 100644 --- a/src/ui/contextSubscriptions.js +++ b/src/ui/contextSubscriptions.js @@ -1,25 +1,38 @@ -import { contextLayerEnableBlockReason, isExplicitUserIntentOrigin, runWithContextModeChanging, settleContextModeChange, shouldCaptureContextSession, shouldDeferContextEntryDuringClear } from '../contextModePolicy.js'; +import { + contextLayerEnableBlockReason, + isExplicitUserIntentOrigin, + runWithContextModeChanging, + settleContextModeChange, + shouldCaptureContextSession, + shouldDeferContextEntryDuringClear, +} from '../contextModePolicy.js'; export function connectContextManager(manager) { this.disconnect(); if (this.destroyed) return; this._dataManager = manager || null; this._contextManagerUnsubscribe = this._dataManager?.subscribe?.((change) => { - if (String(change?.type || '').startsWith('visibility')) this._handleContextLayerChange(change); + if (String(change?.type || '').startsWith('visibility')) + this._handleContextLayerChange(change); }); - if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { - this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => { + if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { + this._dataManagerVisibilityRequestUnsubscribe = + this._dataManager.subscribeVisibilityRequests((change) => { if (this.destroyed) return; if (shouldCaptureContextSession(change)) { // This event is synchronous with intent publication, before an // awaited guard or Clear All can alter the rest of the layer set. // Manager effective visibility already includes both the new entry // intent and Clear's reserved OFF baseline. - this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] }); - if (shouldDeferContextEntryDuringClear({ - change, - clearInFlight: Boolean(this._clearSelectedLayersPromise), - })) { + this._captureContextSessionSnapshot({ + excludeLayerIds: [change.layerId], + }); + if ( + shouldDeferContextEntryDuringClear({ + change, + clearInFlight: Boolean(this._clearSelectedLayersPromise), + }) + ) { this._contextModeDeferredEntryIntent = { layerId: change.layerId, intentEpoch: change.intentEpoch, @@ -29,9 +42,9 @@ export function connectContextManager(manager) { this._syncContextModeButtons(); } } else if ( - change?.layerId === 'rocket-launches' - && change.enabled === false - && isExplicitUserIntentOrigin(change.origin, change.layerId) + change?.layerId === 'rocket-launches' && + change.enabled === false && + isExplicitUserIntentOrigin(change.origin, change.layerId) ) { this._contextModeDeferredEntryIntent = null; if (this._clearSelectedLayersPromise) { @@ -41,11 +54,14 @@ export function connectContextManager(manager) { } } }); - } - if (typeof this._dataManager?.addVisibilityGuard === 'function') { - this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => { + } + if (typeof this._dataManager?.addVisibilityGuard === 'function') { + this._dataManagerVisibilityGuardUnsubscribe = + this._dataManager.addVisibilityGuard(async (change) => { if (this.destroyed) return null; - const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId; + const layerName = + this._dataManager?.layers?.get(change.layerId)?.module?.name || + change.layerId; const reason = contextLayerEnableBlockReason({ contextMode: this._contextModeEntering || this._contextMode, change, @@ -53,23 +69,26 @@ export function connectContextManager(manager) { }); if (reason) return reason; if ( - change.enabled - && ['military-awareness', 'rocket-launches'].includes(change.layerId) - && shouldCaptureContextSession(change) - && ( - !this._contextModeChanging - || ( - change.layerId === 'rocket-launches' - && this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch - ) - ) + change.enabled && + ['military-awareness', 'rocket-launches'].includes(change.layerId) && + shouldCaptureContextSession(change) && + (!this._contextModeChanging || + (change.layerId === 'rocket-launches' && + this._contextModeDeferredEntryIntent?.intentEpoch === + change.intentEpoch)) ) { - const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null; - const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch; + const entryMode = + change.layerId === 'rocket-launches' ? 'space-missions' : null; + const deferredClearEntry = + this._contextModeDeferredEntryIntent?.intentEpoch === + change.intentEpoch; // A deferred entry owns the state after Clear settles. Restoring // Clear's transient busy flag here would leave Context stuck. - const priorChanging = deferredClearEntry ? false : this._contextModeChanging; - const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry'); + const priorChanging = deferredClearEntry + ? false + : this._contextModeChanging; + const notificationToken = + change.notificationToken || Symbol('direct-context-shell-entry'); const ownsNotificationToken = !change.notificationToken; if (ownsNotificationToken) { this._userFacingContextNotificationTokens.add(notificationToken); @@ -80,10 +99,16 @@ export function connectContextManager(manager) { if (deferredClearEntry) { await this._clearSelectedLayersManagerPromise; if (this.destroyed) return false; - if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; + if ( + this._contextModeDeferredEntryIntent?.intentEpoch !== + change.intentEpoch + ) + return false; this._contextModeDeferredEntryIntent = null; } - await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); + await this._clearLayersOutsideContextMode(entryMode, { + notificationToken, + }); if (this.destroyed) return false; } catch (error) { this._contextModeEntering = null; @@ -94,30 +119,37 @@ export function connectContextManager(manager) { notificationToken, }); } catch (restoreError) { - console.warn(`[Context] ${change.layerId} rollback failed`, restoreError); + console.warn( + `[Context] ${change.layerId} rollback failed`, + restoreError, + ); } return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`; } finally { if (ownsNotificationToken) { - this._userFacingContextNotificationTokens.delete(notificationToken); + this._userFacingContextNotificationTokens.delete( + notificationToken, + ); } if (!this.destroyed) settleContextModeChange(this, priorChanging); } } return null; }); - } - if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { - this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { + } + if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { + this._dataManagerBeforeDestroyUnsubscribe = + this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { if (this.destroyed || !this._contextSessionSnapshot) return; await runWithContextModeChanging(this, async () => { this._contextMode = null; this.cockpitView?.exit({ restoreTracking: false }); this._syncContextModeButtons(); - await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] }); + await this._restoreContextSession({ + excludeLayerIds: layerId ? [layerId] : [], + }); }); }); - } + } this._syncContextModeButtons(); } - diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js index 09b8f711c..729edc654 100644 --- a/src/ui/contextTransactions.js +++ b/src/ui/contextTransactions.js @@ -1,215 +1,272 @@ -import { contextAllowedLayerIds, mergeContextTransitionErrors } from '../contextModePolicy.js'; +import { + contextAllowedLayerIds, + mergeContextTransitionErrors, +} from '../contextModePolicy.js'; -export async function _selectContextMode(mode, { notificationToken = null, signal = null } = {}) { - if (this.destroyed || !this._dataManager) return false; - if (this._clearSelectedLayersPromise) return false; - this._contextTransitionFailedLayerIds = []; - const generation = ++this._contextModeGeneration; - const isCurrent = () => !this.destroyed && generation === this._contextModeGeneration; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._contextModeChanging = true; - this._syncContextModeButtons(); - try { - if (mode !== 'flights' && this.cockpitView?.active) { - this.cockpitView.exit({ restoreTracking: false }); - } - if (!mode) { - this._contextMode = null; - this._syncContextModeButtons(); - await this._restoreContextSession({ notificationToken, signal }); - return isCurrent(); - } - // A cross-mode switch dismantles the prior mode BEFORE the new one is - // committed. If the caller aborts in that window the switch never lands, - // and the resting state is Context OFF — reported as such by - // setContextMode rather than dressed up as a clean cancellation. See the - // note above _restoreContextSessionAfterLayerSettles. - const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode); - if (crossModeSwitch) { - this._contextMode = null; - await this._restoreContextSession({ notificationToken, signal }); - if (!isCurrent()) return false; - if (signal?.aborted) return false; - } - this._captureContextSessionSnapshot(); - this._contextMode = mode; +export async function _selectContextMode( + mode, + { notificationToken = null, signal = null } = {}, +) { + if (this.destroyed || !this._dataManager) return false; + if (this._clearSelectedLayersPromise) return false; + this._contextTransitionFailedLayerIds = []; + const generation = ++this._contextModeGeneration; + const isCurrent = () => + !this.destroyed && generation === this._contextModeGeneration; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + this._contextModeChanging = true; + this._syncContextModeButtons(); + try { + if (mode !== 'flights' && this.cockpitView?.active) { + this.cockpitView.exit({ restoreTracking: false }); + } + if (!mode) { + this._contextMode = null; this._syncContextModeButtons(); - // Replay isolation must settle before Space Missions starts. Contacts - // keeps its non-dependency teardown in the background so slow source - // shutdown does not delay cockpit entry. - try { - await this._clearLayersOutsideContextMode(mode, { notificationToken, signal }); - } catch (error) { - if (!isCurrent()) return false; - let transitionError = error; - console.warn(`[Context] ${mode} isolation failed`, error); - this._contextMode = null; - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ notificationToken }); - } catch (restoreError) { - transitionError = mergeContextTransitionErrors(transitionError, restoreError); - this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])]; - throw transitionError; - } - this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; - return false; - } + await this._restoreContextSession({ notificationToken, signal }); + return isCurrent(); + } + // A cross-mode switch dismantles the prior mode BEFORE the new one is + // committed. If the caller aborts in that window the switch never lands, + // and the resting state is Context OFF — reported as such by + // setContextMode rather than dressed up as a clean cancellation. See the + // note above _restoreContextSessionAfterLayerSettles. + const crossModeSwitch = Boolean( + this._contextMode && this._contextMode !== mode, + ); + if (crossModeSwitch) { + this._contextMode = null; + await this._restoreContextSession({ notificationToken, signal }); if (!isCurrent()) return false; - // Entry is one transaction: isolation succeeded above, so a failed mode - // activation must roll the cleared layers back instead of stranding the - // user in a half-entered mode with an orphaned snapshot. - const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches'; - if (mode === 'flights') { - this._dataManager.setLayerParams('military-awareness', { passive: false }); - } - let activated = false; - let activationError = null; - let activationIntent = null; - let terminalIntentOutcome = null; + if (signal?.aborted) return false; + } + this._captureContextSessionSnapshot(); + this._contextMode = mode; + this._syncContextModeButtons(); + // Replay isolation must settle before Space Missions starts. Contacts + // keeps its non-dependency teardown in the background so slow source + // shutdown does not delay cockpit entry. + try { + await this._clearLayersOutsideContextMode(mode, { + notificationToken, + signal, + }); + } catch (error) { + if (!isCurrent()) return false; + let transitionError = error; + console.warn(`[Context] ${mode} isolation failed`, error); + this._contextMode = null; + this._syncContextModeButtons(); try { - activationIntent = this._dataManager._setEnabledWithIntent( - entryLayerId, - true, - { notificationToken, ...(signal ? { signal } : {}) }, + await this._restoreContextSession({ notificationToken }); + } catch (restoreError) { + transitionError = mergeContextTransitionErrors( + transitionError, + restoreError, ); - this._contextModeEntryIntent = { - generation, - layerId: entryLayerId, - intentEpoch: activationIntent.intentEpoch, - }; - activated = await activationIntent.promise; - terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.( + this._contextTransitionFailedLayerIds = [ + ...(transitionError.failedLayerIds || []), + ]; + throw transitionError; + } + this._contextTransitionFailedLayerIds = [ + ...(transitionError?.failedLayerIds || []), + ]; + return false; + } + if (!isCurrent()) return false; + // Entry is one transaction: isolation succeeded above, so a failed mode + // activation must roll the cleared layers back instead of stranding the + // user in a half-entered mode with an orphaned snapshot. + const entryLayerId = + mode === 'flights' ? 'military-awareness' : 'rocket-launches'; + if (mode === 'flights') { + this._dataManager.setLayerParams('military-awareness', { + passive: false, + }); + } + let activated = false; + let activationError = null; + let activationIntent = null; + let terminalIntentOutcome = null; + try { + activationIntent = this._dataManager._setEnabledWithIntent( + entryLayerId, + true, + { notificationToken, ...(signal ? { signal } : {}) }, + ); + this._contextModeEntryIntent = { + generation, + layerId: entryLayerId, + intentEpoch: activationIntent.intentEpoch, + }; + activated = await activationIntent.promise; + terminalIntentOutcome = + await this._dataManager._waitForVisibilityIntent?.( entryLayerId, activationIntent.intentEpoch, ); - } catch (error) { - activationError = error; - } - if (!isCurrent()) return false; - let replacementIntent = mode === 'space-missions' - && this._contextModeReplacementIntent?.generation === generation - && this._contextModeReplacementIntent.layerId === entryLayerId + } catch (error) { + activationError = error; + } + if (!isCurrent()) return false; + let replacementIntent = + mode === 'space-missions' && + this._contextModeReplacementIntent?.generation === generation && + this._contextModeReplacementIntent.layerId === entryLayerId ? this._contextModeReplacementIntent : null; - while (replacementIntent) { - const outcome = await this._dataManager._waitForVisibilityIntent?.( - entryLayerId, - replacementIntent.intentEpoch, - ); - terminalIntentOutcome = outcome; - if (!isCurrent()) return false; - const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch - && outcome.enabled === true - && outcome.succeeded === true; - if (replacementOwnsMode) { - this._contextModeEntering = null; - this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - this._syncContextModeButtons(); - return true; - } - const successorEpoch = outcome?.cancellationReason === 'superseded' - && outcome.successorEnabled === true - && Number.isInteger(outcome.successorIntentEpoch) - && outcome.successorIntentEpoch > replacementIntent.intentEpoch - ? outcome.successorIntentEpoch - : null; - replacementIntent = successorEpoch === null ? null : { - generation, - layerId: entryLayerId, - intentEpoch: successorEpoch, - }; - } - if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) { - const cancelledAndSettled = terminalIntentOutcome?.succeeded === false - && ['caller-abort', 'resource-abort', 'superseded'].includes( - terminalIntentOutcome.cancellationReason, - ); - let transitionError = null; - if (!cancelledAndSettled) { - transitionError = activationError instanceof Error - ? activationError - : new Error(`Context activation failed for: ${entryLayerId}`); - transitionError.failedLayerIds = [...new Set([ - ...(transitionError.failedLayerIds || []), - entryLayerId, - ])]; - this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds]; - console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled'); - } - this._contextMode = null; + while (replacementIntent) { + const outcome = await this._dataManager._waitForVisibilityIntent?.( + entryLayerId, + replacementIntent.intentEpoch, + ); + terminalIntentOutcome = outcome; + if (!isCurrent()) return false; + const replacementOwnsMode = + outcome?.intentEpoch === replacementIntent.intentEpoch && + outcome.enabled === true && + outcome.succeeded === true; + if (replacementOwnsMode) { + this._contextModeEntering = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; this._syncContextModeButtons(); - try { - await this._restoreContextSession({ - excludeLayerIds: [entryLayerId], - notificationToken, - }); - } catch (restoreError) { - transitionError = mergeContextTransitionErrors(transitionError, restoreError); - this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; - throw transitionError; - } - // `null` means the requested entry was cancelled and its exact rollback - // completed. The action wrapper treats that as a silent non-commit, - // while callers still require literal `true` before expanding Context. - return cancelledAndSettled ? null : false; - } - this._contextModeEntryIntent = null; - return true; - } finally { - if (isCurrent()) { - this._contextModeChanging = false; - this._syncContextModeButtons(); + return true; } + const successorEpoch = + outcome?.cancellationReason === 'superseded' && + outcome.successorEnabled === true && + Number.isInteger(outcome.successorIntentEpoch) && + outcome.successorIntentEpoch > replacementIntent.intentEpoch + ? outcome.successorIntentEpoch + : null; + replacementIntent = + successorEpoch === null + ? null + : { + generation, + layerId: entryLayerId, + intentEpoch: successorEpoch, + }; + } + if ( + activationError || + activated === false || + !this._dataManager.isEnabled(entryLayerId) + ) { + const cancelledAndSettled = + terminalIntentOutcome?.succeeded === false && + ['caller-abort', 'resource-abort', 'superseded'].includes( + terminalIntentOutcome.cancellationReason, + ); + let transitionError = null; + if (!cancelledAndSettled) { + transitionError = + activationError instanceof Error + ? activationError + : new Error(`Context activation failed for: ${entryLayerId}`); + transitionError.failedLayerIds = [ + ...new Set([...(transitionError.failedLayerIds || []), entryLayerId]), + ]; + this._contextTransitionFailedLayerIds = [ + ...transitionError.failedLayerIds, + ]; + console.warn( + `[Context] ${mode} activation failed; restoring previous layers`, + activationError || 'not enabled', + ); + } + this._contextMode = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + this._syncContextModeButtons(); + try { + await this._restoreContextSession({ + excludeLayerIds: [entryLayerId], + notificationToken, + }); + } catch (restoreError) { + transitionError = mergeContextTransitionErrors( + transitionError, + restoreError, + ); + this._contextTransitionFailedLayerIds = [ + ...(transitionError?.failedLayerIds || []), + ]; + throw transitionError; + } + // `null` means the requested entry was cancelled and its exact rollback + // completed. The action wrapper treats that as a silent non-commit, + // while callers still require literal `true` before expanding Context. + return cancelledAndSettled ? null : false; } - } - -export async function _deactivateContextForLayerChange({ notificationToken = null } = {}) { - this._contextModeGeneration += 1; - this._contextModeChanging = true; - this._contextMode = null; this._contextModeEntryIntent = null; - this._contextModeReplacementIntent = null; - if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false }); - this._syncContextModeButtons(); - try { - await this._restoreContextSession({ notificationToken }); - } finally { + return true; + } finally { + if (isCurrent()) { this._contextModeChanging = false; this._syncContextModeButtons(); } } +} + +export async function _deactivateContextForLayerChange({ + notificationToken = null, +} = {}) { + this._contextModeGeneration += 1; + this._contextModeChanging = true; + this._contextMode = null; + this._contextModeEntryIntent = null; + this._contextModeReplacementIntent = null; + if (this.cockpitView?.active) + this.cockpitView.exit({ restoreTracking: false }); + this._syncContextModeButtons(); + try { + await this._restoreContextSession({ notificationToken }); + } finally { + this._contextModeChanging = false; + this._syncContextModeButtons(); + } +} export async function _clearLayersOutsideContextMode( - mode = null, - { notificationToken = null, signal = null } = {}, - ) { - const allowed = contextAllowedLayerIds(mode); - const pending = []; - for (const [layerId] of this._dataManager.layers || []) { - // Effective visibility: a disallowed layer still mid-ENABLING must be - // isolated too, or it settles ON inside the exclusive mode. - if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) { - pending.push({ - layerId, - transition: this._dataManager.setEnabled(layerId, false, { - notificationToken, - ...(signal ? { signal } : {}), - }), - }); - } - } - const results = await Promise.all(pending.map(({ transition }) => transition)); - const failed = pending - .filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId)) - .map(({ layerId }) => layerId); - if (failed.length > 0) { - const error = new Error(`Context isolation failed for: ${failed.join(', ')}`); - error.failedLayerIds = failed; - throw error; + mode = null, + { notificationToken = null, signal = null } = {}, +) { + const allowed = contextAllowedLayerIds(mode); + const pending = []; + for (const [layerId] of this._dataManager.layers || []) { + // Effective visibility: a disallowed layer still mid-ENABLING must be + // isolated too, or it settles ON inside the exclusive mode. + if ( + !allowed.has(layerId) && + this._dataManager.isEffectivelyEnabled(layerId) + ) { + pending.push({ + layerId, + transition: this._dataManager.setEnabled(layerId, false, { + notificationToken, + ...(signal ? { signal } : {}), + }), + }); } } + const results = await Promise.all( + pending.map(({ transition }) => transition), + ); + const failed = pending + .filter( + ({ layerId }, index) => + results[index] === false || this._dataManager.isEnabled(layerId), + ) + .map(({ layerId }) => layerId); + if (failed.length > 0) { + const error = new Error( + `Context isolation failed for: ${failed.join(', ')}`, + ); + error.failedLayerIds = failed; + throw error; + } +} From 350a1afd4f8a38239244158411db7d1e23815f7e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 01:43:18 -0700 Subject: [PATCH 40/92] style: format Context package boundary declaration --- scripts/package-boundaries.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 1b46e4339..3ebec17a5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -371,10 +371,7 @@ "external": [] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", From b1e67a6a50fc4cd838ea5c691b68be0e193f2bf6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 01:43:18 -0700 Subject: [PATCH 41/92] test: retain Context invariants across formatted module boundaries --- src/contextSessionOrdering.test.mjs | 30 ++++++++++++++--------------- src/contextTabKeyboard.test.mjs | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index a1555c6e7..7fe6a8288 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -64,7 +64,7 @@ test('direct Context entry captures on the synchronous request boundary', () => ); assert.match( attach, - /_captureContextSessionSnapshot\(\{ excludeLayerIds: \[change\.layerId\] \}\)/, + /_captureContextSessionSnapshot\(\{\s*excludeLayerIds:\s*\[change\.layerId\],?\s*\}\)/, ); }); @@ -112,7 +112,7 @@ test('context handler: either failed direct Context-shell start rolls the sessio assert.match(failedBranch, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/); assert.match( failedBranch, - /_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{ notificationToken \},\s*\)/, + /_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{\s*notificationToken,?\s*\},?\s*\)/, ); assert.match(failedBranch, /_runUserFacingContextAction/); assert.match(failedBranch, /const failureMessage =/); @@ -120,11 +120,11 @@ test('context handler: either failed direct Context-shell start rolls the sessio failedBranch, /await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\.showToast\(failureMessage\);\s*return true;/, ); - assert.match(failedBranch, /\},\s*failureMessage,\s*\)\);/); + assert.match(failedBranch, /\},\s*failureMessage,?\s*\),?\s*\);/); assert.match(failedBranch, /_trackContextLayerReaction\(/); assert.match( failedBranch, - /else if \(!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\)/, + /else if \(\s*!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\s*\)/, ); const deferredRestore = _restoreContextSessionAfterLayerSettles.toString(); @@ -152,7 +152,7 @@ test('every user-facing Context exit route settles through the failure surface', assert.doesNotMatch(initPanel, /void this\._selectContextMode/); const deactivationCalls = [...handler.matchAll( - /void this\._trackContextLayerReaction\(this\._runUserFacingContextAction\(\(notificationToken\) => \(\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\)\s*\)\)\)/g, + /void this\._trackContextLayerReaction\(\s*this\._runUserFacingContextAction\(\(notificationToken\) =>\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\),?\s*\),?\s*\)/g, )]; assert.equal(deactivationCalls.length, 4, 'dependency and primary layer exits share the caught restore path'); assert.doesNotMatch(handler, /void this\._deactivateContextForLayerChange\(\)/); @@ -190,8 +190,8 @@ test('right-rail context entry is transactional: activation result gates the mod assert.match(failureBlock, /await this\._dataManager\._waitForVisibilityIntent\?\.\(/); assert.match(failureBlock, /outcome\?\.intentEpoch === replacementIntent\.intentEpoch[\s\S]*?outcome\.succeeded === true/); assert.match(failureBlock, /outcome\?\.cancellationReason === 'superseded'[\s\S]*?outcome\.successorEnabled === true[\s\S]*?outcome\.successorIntentEpoch > replacementIntent\.intentEpoch/); - assert.match(failureBlock, /activationError \|\| activated === false \|\| !this\._dataManager\.isEnabled\(entryLayerId\)/); - assert.match(failureBlock, /cancelledAndSettled = terminalIntentOutcome\?\.succeeded === false/); + assert.match(failureBlock, /activationError\s*\|\|\s*activated === false\s*\|\|\s*!this\._dataManager\.isEnabled\(entryLayerId\)/); + assert.match(failureBlock, /cancelledAndSettled\s*=\s*terminalIntentOutcome\?\.succeeded === false/); assert.match(failureBlock, /\['caller-abort', 'resource-abort', 'superseded'\]/); assert.match(failureBlock, /this\._contextMode = null/); assert.match(failureBlock, /await this\._restoreContextSession\(\{[\s\S]*?excludeLayerIds: \[entryLayerId\],[\s\S]*?notificationToken/); @@ -200,7 +200,7 @@ test('right-rail context entry is transactional: activation result gates the mod test('Context entry awaits isolation and direct shell routes isolate in the visibility guard', () => { const select = _selectContextMode.toString(); - assert.match(select, /await this\._clearLayersOutsideContextMode\(mode, \{ notificationToken, signal \}\)/); + assert.match(select, /await this\._clearLayersOutsideContextMode\(mode,\s*\{\s*notificationToken,\s*signal,?\s*\}\)/); assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/); assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/); @@ -236,14 +236,14 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi const guard = connectContextManager.toString(); assert.match(guard, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/); - assert.match(guard, /const notificationToken = change\.notificationToken \|\| Symbol\('direct-context-shell-entry'\)/); + assert.match(guard, /const notificationToken\s*=\s*change\.notificationToken\s*\|\|\s*Symbol\('direct-context-shell-entry'\)/); assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); - assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode, \{ notificationToken \}\)/); + assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode,\s*\{\s*notificationToken,?\s*\}\)/); assert.match( guard, /await this\._restoreContextSession\(\{\s*excludeLayerIds: \[change\.layerId\],\s*notificationToken,\s*\}\)/, ); - assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(notificationToken\)/); + assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(\s*notificationToken,?\s*\)/); }); test('a lost cross-mode switch says Context is off, and the state agrees', () => { @@ -255,7 +255,7 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () => assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost'); assert.match( setter, - /const crossModeSwitchLost = transitioned !== true\s*&& Boolean\(priorMode\) && priorMode !== canonical && !state\.mode;/, + /const crossModeSwitchLost\s*=\s*transitioned !== true\s*&&\s*Boolean\(priorMode\)\s*&&\s*priorMode !== canonical\s*&&\s*!state\.mode;/, 'the verdict requires a real cross-mode switch that ended with no mode', ); assert.match( @@ -300,11 +300,11 @@ test('Context facade preserves success when cancellation arrives after commit', test('Context production rollback paths merge primary and restore failed-layer identities', () => { const select = _selectContextMode.toString(); assert.equal( - (select.match(/mergeContextTransitionErrors\(transitionError, restoreError\)/g) || []).length, + (select.match(/mergeContextTransitionErrors\(\s*transitionError,\s*restoreError,?\s*\)/g) || []).length, 2, ); - assert.match(select, /transitionError\.failedLayerIds = \[\.\.\.new Set\(\[/); - assert.match(select, /this\._contextTransitionFailedLayerIds = \[\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\)\]/); + assert.match(select, /transitionError\.failedLayerIds = \[\s*\.\.\.new Set\(\[/); + assert.match(select, /this\._contextTransitionFailedLayerIds = \[\s*\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\),?\s*\]/); }); test('stale Context cancellation preserves rollback failed-layer identities', () => { diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 484770e2d..20b972967 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -23,7 +23,7 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence' } const syncSource = _syncContextModeButtons.toString(); - assert.match(syncSource, /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/); + assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/); assert.match(syncSource, /button\.tabIndex = 0/); assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/); }); @@ -100,7 +100,7 @@ test('Context activation and Clear All never native-disable tabs and guard repea const init = _initGlobalContextPanel.toString(); const select = _selectContextMode.toString(); const clear = clearSelectedLayers.toString(); - assert.equal((init.match(/if \(this\.destroyed \|\| this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2); + assert.equal((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2); assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); }); From 0431dbb05edc83d68b4b8dc25333039534ba41a1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 02:03:18 -0700 Subject: [PATCH 42/92] test: preserve the layout pass in the synthetic Cockpit fixture --- scripts/qa-radio.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 4c9edcd86..40f34240f 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2031,6 +2031,10 @@ async function main() { }; const waitForHudSettle = async () => { await new Promise((resolve) => setTimeout(resolve, 560)); + // The synthetic shell below stops the aircraft/context update loop. + // Restore its layout pass after the HUD fade before measuring geometry; + // qa-cockpit-utility exercises scheduling with the real controller. + manager.cockpitView.scheduleContextLayout(); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }; const prior = { From 8075c70e6e8b7f023e394258342dde6f833652b1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 02:33:38 -0700 Subject: [PATCH 43/92] refactor: separate Cockpit controller and presentation components --- CHANGELOG.md | 3 + docs/CODE-BOUNDARIES.md | 10 + docs/CURRENT-STATE.md | 10 + package.json | 6 +- scripts/package-boundaries.json | 29 + src/cameraHandoff.test.mjs | 18 +- src/cockpitMarkup.test.mjs | 143 +- src/cockpitSignalFocus.test.mjs | 22 +- src/panelEscape.test.mjs | 3 +- src/ui.js | 1878 +------------------------- src/ui/cockpit.js | 2 + src/ui/cockpitBriefing.js | 192 +++ src/ui/cockpitCamera.js | 225 +++ src/ui/cockpitContext.js | 134 ++ src/ui/cockpitController.js | 259 ++++ src/ui/cockpitController.test.mjs | 99 ++ src/ui/cockpitDisplayPortal.js | 90 ++ src/ui/cockpitDisplayPortal.test.mjs | 106 ++ src/ui/cockpitInput.js | 54 + src/ui/cockpitInstruments.js | 182 +++ src/ui/cockpitLayout.js | 152 +++ src/ui/cockpitPresentation.js | 169 +++ src/ui/cockpitSignals.js | 174 +++ src/ui/cockpitTrackingController.js | 226 ++++ 24 files changed, 2258 insertions(+), 1928 deletions(-) create mode 100644 src/ui/cockpit.js create mode 100644 src/ui/cockpitBriefing.js create mode 100644 src/ui/cockpitCamera.js create mode 100644 src/ui/cockpitContext.js create mode 100644 src/ui/cockpitController.js create mode 100644 src/ui/cockpitController.test.mjs create mode 100644 src/ui/cockpitDisplayPortal.js create mode 100644 src/ui/cockpitDisplayPortal.test.mjs create mode 100644 src/ui/cockpitInput.js create mode 100644 src/ui/cockpitInstruments.js create mode 100644 src/ui/cockpitLayout.js create mode 100644 src/ui/cockpitPresentation.js create mode 100644 src/ui/cockpitSignals.js create mode 100644 src/ui/cockpitTrackingController.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b655b91..50c0a3d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations. +- Give Display portal moves cancellable focus/scroll restoration and terminal cleanup. + - Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal. - Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index bea9fd09a..2c89f7ff4 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -310,3 +310,13 @@ subscriptions. Composition supplies the manager, installations search and explicit visual/panel actions. `ui/context/policy` exposes the existing pure mode and restoration rules. No source transport or renderer is imported by these components; initial state and action results retain their existing shape. + +## Cockpit controls + +`ui/cockpit` supplies the Cockpit controller and Display portal. Camera updates, +instruments, Context readouts, briefings, signals, layout and input have separate +modules. Composition supplies the existing aircraft/awareness operations, terrain +cache and sampling operations, continuous-render owner and regional briefing +service. Pure math, utility layout and vision helpers have explicit exports. +Disposal releases subscriptions and pending work; portal moves preserve the +original Display groups, independent scroll positions and current focus owner. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d2d624a2b..f17e4448d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,15 @@ # God's Eye View Current State +## Cockpit component ownership + +Cockpit presentation is separated from its camera/controller behavior. Existing +layer, terrain and rendering operations are supplied by composition, retaining +the same tracked identity, ground acquisition, motion correction and cadence. +The Display portal owns group anchors, focus/scroll restoration and listeners. +Superseded portal frames cannot repaint old state or steal focus after disposal; +retained Cockpit actions cannot restart a disposed controller. + + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, diff --git a/package.json b/package.json index 4cca89785..b8d871123 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,10 @@ "./ui/radio": "./src/ui/radio.js", "./ui/cctv": "./src/ui/cctv.js", "./ui/context": "./src/ui/context.js", - "./ui/context/policy": "./src/contextModePolicy.js" + "./ui/context/policy": "./src/contextModePolicy.js", + "./ui/cockpit": "./src/ui/cockpit.js", + "./ui/cockpit/math": "./src/cockpitMath.js", + "./ui/cockpit/layout": "./src/cockpitUtilityLayout.js", + "./ui/cockpit/vision": "./src/cockpitVisionPolicy.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3ebec17a5..0cb54b36f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -387,5 +387,34 @@ "src/ui/panelRailGeometry.js" ], "external": [] + }, + "cockpit-controls": { + "exports": [ + "./ui/cockpit", + "./ui/cockpit/math", + "./ui/cockpit/layout", + "./ui/cockpit/vision" + ], + "modules": [ + "src/ui/cockpit.js", + "src/ui/cockpitBriefing.js", + "src/ui/cockpitCamera.js", + "src/ui/cockpitContext.js", + "src/ui/cockpitController.js", + "src/ui/cockpitDisplayPortal.js", + "src/ui/cockpitInput.js", + "src/ui/cockpitInstruments.js", + "src/ui/cockpitLayout.js", + "src/ui/cockpitPresentation.js", + "src/ui/cockpitSignals.js", + "src/ui/cockpitTrackingController.js", + "src/cockpitMath.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/ui/panelRailGeometry.js" + ], + "external": [ + "cesium" + ] } } diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index e92ec2a56..5b3018964 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,3 +1,5 @@ +import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; +import { CockpitViewController } from './ui/cockpitController.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -29,11 +31,7 @@ function ordered(source, needles, label) { } test('Cockpit takeover invalidates deferred work before camera cancellation', () => { - const enter = body( - ui, - /enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/, - 'Cockpit enter', - ); + const enter = cockpitEnter.toString(); ordered(enter, [ 'if (!info || !entity?.position) return false;', 'this.onCameraTakeover?.();', @@ -131,18 +129,14 @@ test('voice Cockpit next/previous shares the manual Context navigation path', () // is what stamps. Divergence here is how a voice-only camera path escapes // the arbiter. assert.match( - ui, + CockpitViewController.toString(), /this\._listen\(this\.contextPrevious, 'click', \(\) => this\.navigateContext\(-1, \{ origin: 'user' \}\)\);/, ); assert.match( - ui, + CockpitViewController.toString(), /this\._listen\(this\.contextNext, 'click', \(\) => this\.navigateContext\(1, \{ origin: 'user' \}\)\);/, ); - const funnel = body( - ui, - /navigateContext\(direction, options = \{\}\) \{([\s\S]*?)\n \}\n\n \/\*\* Adopt/, - 'Cockpit Context navigation funnel', - ); + const funnel = navigateContext.toString(); ordered(funnel, [ "const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';", 'const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;', diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index af93a0c65..cefe27afd 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,3 +1,11 @@ +import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; +import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; +import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js'; +import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js'; +import { updateContext as cockpitContext } from './ui/cockpitContext.js'; +import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js'; +import { setBriefAutoRotate } from './ui/cockpitBriefing.js'; +import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js'; import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; import { clearSelectedLayers } from './ui/contextActions.js'; import { _selectContextMode } from './ui/contextTransactions.js'; @@ -72,9 +80,9 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => { }); test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => { - assert.match(ui, /const modes = COCKPIT_VISION_MODES;/); - assert.match(ui, /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/); - assert.doesNotMatch(ui, /none: 'NONE'/); + assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/); + assert.match(setVisionMode.toString(), /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/); + assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/); assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/); assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/); assert.match(ui, /const target = applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);/); @@ -99,20 +107,20 @@ test('Contacts uses the approved radar icon', () => { }); test('Cockpit Escape handling precedes form-control shortcut suppression and focus is restored', () => { - const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/); + const keydown = cockpitKeyDown.toString(); assert.ok(keydown, 'Cockpit keyboard handler is missing'); - const escapeIndex = keydown[1].indexOf("event.key === 'Escape'"); - const formGuardIndex = keydown[1].indexOf("closest?.('input, textarea, select, [contenteditable]')"); + const escapeIndex = keydown.indexOf("event.key === 'Escape'"); + const formGuardIndex = keydown.indexOf("closest?.('input, textarea, select, [contenteditable]')"); assert.ok(escapeIndex >= 0 && formGuardIndex > escapeIndex, 'Escape must work while focus is inside a form control'); - const enter = ui.match(/\n enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/); - const exit = ui.match(/\n exit\(\{ restoreTracking = true \} = \{\}\) \{([\s\S]*?)\n \}\n\n update\(\)/); + const enter = cockpitEnter.toString(); + const exit = cockpitExit.toString(); assert.ok(enter && exit, 'Cockpit entry/exit methods are missing'); - assert.match(enter[1], /activeElement/); - assert.match(enter[1], /mapViewButton.*focus|focus.*mapViewButton/s); - assert.match(exit[1], /focus\(\{ preventScroll: true \}\)/); - assert.match(exit[1], /this\.restoreTrackingFrame\(entity\)/); - assert.doesNotMatch(exit[1], /applyTrackedCameraFrame/); + assert.match(enter, /activeElement/); + assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s); + assert.match(exit, /focus\(\{ preventScroll: true \}\)/); + assert.match(exit, /this\.restoreTrackingFrame\(entity\)/); + assert.doesNotMatch(exit, /applyTrackedCameraFrame/); assert.match( ui, /restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/, @@ -121,24 +129,22 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc }); test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => { - const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/); + const keydown = cockpitKeyDown.toString(); assert.ok(keydown, 'Cockpit keyboard handler is missing'); - assert.match(keydown[1], /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/); - assert.match(keydown[1], /#cockpit-utility-controls \[aria-expanded="true"\]/); + assert.match(keydown, /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/); + assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/); assert.match( - keydown[1], + keydown, /const cockpitAttempt = !!\(this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, ); }); test('the Contact panel never hides itself out from under its own NEXT button', () => { - const updateContext = ui.match(/\n updateContext\(info, heading\) \{([\s\S]*?)\n \}\n\n scheduleContextLayout\(\)/); + const updateContext = cockpitContext.toString(); assert.ok(updateContext, 'Cockpit updateContext is missing'); - const body = updateContext[1]; - // ui.js cannot be imported under node (Cesium's `mgrs` dependency), so the - // decision lives in cockpitMath.resolveCockpitContextReadout and this pins - // ui.js to it. The panel hosts PREVIOUS/NEXT: the only reason it may hide is - // that no snapshot exists at all. + const body = updateContext; + // Pin the real presentation owner to the readout policy: PREVIOUS/NEXT + // remains reachable unless no Context snapshot exists at all. assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/); assert.match(body, /if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/); assert.equal( @@ -177,13 +183,13 @@ test('the Contact panel never hides itself out from under its own NEXT button', }); test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => { - const read = ui.match(/\n readAircraftInfo\(\) \{([\s\S]*?)\n \}/); + const read = readAircraftInfo.toString(); assert.ok(read, 'readAircraftInfo is missing'); - assert.match(read[1], /resolveTrackedAircraftInfo\(\{/); - assert.match(read[1], /gevTrackedId/); + assert.match(read, /resolveTrackedAircraftInfo\(\{/); + assert.match(read, /gevTrackedId/); // In cockpit mode the controller moves the entity off viewer.trackedEntity, // so its own handle is the tracked identity there. - assert.match(read[1], /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/); + assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/); }); test('programmatic Context layer changes cannot bypass explicit expansion policy', () => { @@ -278,8 +284,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', ); assert.match(css, /\.cockpit-utility-controls\.layout-primary-only[\s\S]*?display:\s*none/); assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*?\.cockpit-utility-controls:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?display:\s*none/); - assert.match(ui, /resolveCockpitUtilityLayout\(\{ availableHeight, expandedHeight, collapsedHeight \}\)/); - assert.match(ui, /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/); + assert.match(syncSignalLayout.toString(), /resolveCockpitUtilityLayout\(\{ availableHeight, expandedHeight, collapsedHeight \}\)/); + assert.match(syncSignalLayout.toString(), /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/); }); test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => { @@ -317,11 +323,12 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat }); test('Cockpit Display portal retains both scroll owners across round trips', () => { - assert.match(ui, /this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop \|\| 0/); - assert.match(ui, /this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop \|\| 0/); - assert.match(ui, /if \(!this\._cockpitDisplayPortalActive\)[\s\S]*?this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop/); - assert.match(ui, /if \(this\._cockpitDisplayPortalActive\)[\s\S]*?this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop/); - assert.match(ui, /this\._cockpitDisplayPanel\.scrollTop = this\._cockpitDisplayScrollTop[\s\S]*?this\._ppToggles\.scrollTop = this\._standardDisplayScrollTop/); + const portal = CockpitDisplayPortal.toString(); + assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/); + assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/); + assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/); + assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/); + assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/); }); test('Cockpit side surfaces behave as two single-expanded accordions', () => { @@ -343,16 +350,16 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => { /nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/, 'closing Data Layers must restore Contact only after an automatic collapse', ); - assert.match(ui, + assert.match(setContextCollapsed.toString(), /const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/, 'Contact expansion must notify only on a collapsed-to-expanded transition', ); - assert.match(ui, + assert.match(setSignalCollapsed.toString(), /const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/, 'Live Signals expansion must notify only on a collapsed-to-expanded transition', ); assert.match( - ui, + setSignalCollapsed.toString(), /setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/, ); assert.match( @@ -473,8 +480,8 @@ test('Reset releases Contact camera ownership through its selection-preserving r assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/); assert.match(controls, /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/, 'both reset controls delegate to the same supplied reset action'); - assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/); - assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/); + assert.match(cockpitEnter.toString(), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/); + assert.match(readRadioSource(new URL('./ui/cockpitTrackingController.js', import.meta.url), 'utf8'), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/); }); test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => { @@ -536,27 +543,27 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { 'the right margin must not borrow the left accordion corridor: it is solved ' + 'against left-lane obstacles and put the strip through the briefing card', ); - const signalLayout = ui.match(/syncSignalLayout\(\) \{([\s\S]*?)\n \}\n\n dispose\(\)/); + const signalLayout = syncSignalLayout.toString(); assert.ok(signalLayout, 'Cockpit signal layout method is missing'); assert.match( - signalLayout[1], + signalLayout, /setProperty\('--cockpit-utility-top', `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`\)/, 'Cockpit owns the utility strip anchor and republishes it every layout tick', ); assert.match( - signalLayout[1], + signalLayout, /resolveCockpitUtilityAnchor\(\{[\s\S]*?recBottom: recBounds \? recBounds\.bottom : 0,[\s\S]*?signalTop: signalBounds\.top,[\s\S]*?stripHeight: utilityBounds\.height,/, 'the strip hangs off the REC readout and is clamped by the briefing card it shares the margin with', ); - assert.match(signalLayout[1], /#intel-hud \.hud-top-right/); + assert.match(signalLayout, /#intel-hud \.hud-top-right/); assert.match( - signalLayout[1], + signalLayout, /const recBounds = isRenderedOnScreen\(recReadout\) \? recReadout\.getBoundingClientRect\(\) : null;/, 'HUD Off retires the Intel HUD with visibility/opacity, which leaves the REC ' + 'readout a rect — a rect test alone would anchor the strip to an invisible readout', ); assert.match( - ui, + isRenderedOnScreen.toString(), /function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none' \|\| style\.visibility === 'hidden' \|\| Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/, ); assert.match( @@ -566,12 +573,12 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { + 'readout keeps its rect until the transition ends', ); assert.doesNotMatch( - signalLayout[1], + signalLayout, /Math\.max\(120,/, 'the 120px corridor floor can never be reached by a 107px strip and only hid the collision', ); - assert.match(signalLayout[1], /const availableHeight = utilityAnchor\.maxHeight;/); - assert.match(signalLayout[1], /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/); + assert.match(signalLayout, /const availableHeight = utilityAnchor\.maxHeight;/); + assert.match(signalLayout, /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/); assert.match( css, /\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(--cockpit-utility-top, var\(--left-stack-safe-top, var\(--left-stack-top\)\)\)/, @@ -637,16 +644,16 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls ); assert.doesNotMatch(ui, /\['presets',/); assert.match( - ui, - /group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)/, + CockpitDisplayPortal.toString(), + /group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed'/, ); assert.match( - ui, - /_setCockpitDisplayPortalActive\(active\) \{[\s\S]*?record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/, + CockpitDisplayPortal.prototype.setActive.toString(), + /record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/, ); assert.match( - ui, - /window\.removeEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)[\s\S]*?_setCockpitDisplayPortalActive\(false\)[\s\S]*?record\.anchor\.remove\(\)/, + CockpitDisplayPortal.prototype.destroy.toString(), + /setActive\(false, \{ settle: false \}\)[\s\S]*?listeners\.abort\(\)[\s\S]*?record\.anchor\.remove\(\)/, ); assert.doesNotMatch(ui, /_cycleCockpitHud|_cockpitModels3dToggle|_cockpitDetectionToggle/); assert.equal((html.match(/id="style-buttons"/g) || []).length, 1); @@ -656,7 +663,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/); assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/); assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/); - assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/); + assert.match(cycleVisionMode.toString(), /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/); assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/); assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/); assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{[\s\S]*?min-width:\s*0;/); @@ -694,14 +701,12 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig }); test('cockpit aircraft handoff invalidates the prior world-position anchor', () => { - const match = ui.match( - /_adoptTrackedEntity\(nowMs, suppliedInfo = null\) \{([\s\S]*?)\n \}\n\n setVisionMode/, - ); + const match = _adoptTrackedEntity.toString(); assert.ok(match, 'cockpit tracked-entity handoff block is missing'); - assert.match(match[1], /this\.viewer\.trackedEntity = undefined;/); - assert.match(match[1], /this\.cockpitAnchorValid = false;/); - assert.match(match[1], /this\.heading = normalizeHeading\(info\.track \?\? 0\);/); - assert.match(match[1], /this\.lastFrameMs = nowMs;/); + assert.match(match, /this\.viewer\.trackedEntity = undefined;/); + assert.match(match, /this\.cockpitAnchorValid = false;/); + assert.match(match, /this\.heading = normalizeHeading\(info\.track \?\? 0\);/); + assert.match(match, /this\.lastFrameMs = nowMs;/); }); test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => { @@ -726,7 +731,7 @@ test('cockpit summary presents the focused item as Contact', () => { assert.match(match[0], /aria-label="Next — nearest unvisited contact in the 250 km window"/); assert.match(match[0], /aria-label="Collapse Contact panel"/); assert.doesNotMatch(match[0], />GLOBAL CONTEXT { @@ -776,14 +781,12 @@ test('cockpit briefing cycle control keeps its state as the accessible name', () assert.match(match[0], />CYCLE OFF<\/button>/); assert.match(match[0], /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./); - const update = ui.match( - /setBriefAutoRotate\(enabled\) \{([\s\S]*?)\n \}\n\n startBriefRotation/, - ); + const update = setBriefAutoRotate.toString(); assert.ok(update, 'cockpit briefing cycle state updater is missing'); - assert.match(update[1], /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/); - assert.match(update[1], /setAttribute\('aria-label', label\)/); - assert.match(update[1], /\.title = help;/); - assert.doesNotMatch(update[1], /setAttribute\('aria-label', help\)/); + assert.match(update, /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/); + assert.match(update, /setAttribute\('aria-label', label\)/); + assert.match(update, /\.title = help;/); + assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/); }); test('voice Cockpit entry honours a requested contact layer before it enters', () => { diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs index 56d5c9d33..57e9ecd6f 100644 --- a/src/cockpitSignalFocus.test.mjs +++ b/src/cockpitSignalFocus.test.mjs @@ -1,14 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { afterEach } from 'node:test'; import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js'; - -const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); -const renderStart = source.indexOf(' renderCockpitSignals() {'); -const renderEnd = source.indexOf(' setContextCollapsed(', renderStart); -assert.ok(renderStart >= 0 && renderEnd > renderStart); -const clickSource = source.match(/this\._listen\(this\.signalList, 'click', (\(event\) => \{[\s\S]*?\n \})\);/)?.[1]; -assert.ok(clickSource, 'the installed signal click route exists'); +import { CockpitViewController } from './ui/cockpitController.js'; +const previousDocument = globalThis.document; +afterEach(() => { globalThis.document = previousDocument; }); // Model native focus loss on removal AND on ordinary DOM moves. Keeping an // object reference alone is insufficient if reconciliation disconnects it. @@ -84,8 +80,9 @@ function fixture() { document.body = new Node('body'); document.createElement = (tag) => new Node(tag); document.activeElement = document.body; - const controller = new (new Function('document', 'formatAwarenessLabel', - `return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))(); + globalThis.document = document; + const controller = Object.create(CockpitViewController.prototype); + controller.services = { formatAwarenessLabel }; controller.signalList = new Node('ol'); controller.signalToggle = new Node('button'); controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')]; @@ -98,9 +95,8 @@ function fixture() { const outside = new Node('button'); document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside); const selected = []; - const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({ - focusTarget: (...args) => selected.push(args), - }); + controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) }; + const click = event => controller.handleSignalClick(event); const buttons = () => controller.signalList.querySelectorAll('button'); const tab = () => { const nodes = document.body.querySelectorAll('button'); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index c44be1b97..cbcd4f591 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -1,3 +1,4 @@ +import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); @@ -139,7 +140,7 @@ test('panel chrome wires Escape for every declared collapse target', () => { }); test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => { - const onKeyDown = method('onKeyDown', 'enter'); + const onKeyDown = cockpitKeyDown.toString(); assert.match( onKeyDown, /event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/, diff --git a/src/ui.js b/src/ui.js index 72e5f8a4c..66f0b5955 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,3 +1,4 @@ +import { CockpitViewController, CockpitDisplayPortal } from './ui/cockpit.js'; import { ContextControls } from './ui/context.js'; import { CctvControls } from './ui/cctv.js'; import { RadioControls } from './ui/radio.js'; @@ -10,13 +11,8 @@ import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInpu import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js'; import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js'; import * as Cesium from 'cesium'; -import { - BLOOM_INTENSITY_DEFAULT, - BLOOM_SCALE_VERSION, - clampBloomIntensity, - decodeBloomIntensity, -} from './bloom.js'; -import { LOCATIONS, CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js'; +import { BLOOM_SCALE_VERSION, clampBloomIntensity, decodeBloomIntensity } from './bloom.js'; +import { CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js'; import { interruptCameraMotion } from './cameraVerbs.js'; import { aircraftTrackingTarget, @@ -70,31 +66,11 @@ import aisLiveVesselsLayer from './data/aisLiveVessels.js'; import militaryAwarenessLayer from './data/militaryAwareness.js'; import militaryInstallationsLayer from './data/militaryInstallations.js'; import rocketLaunchesLayer from './data/rocketLaunches.js'; -import { - aggregateLayerLoading, - canPresentDeferredStatusNotice, - createGlobalStatusNotice, - createLoadingFeedbackState, - createTrafficSyncFeedbackState, - presentGlobalLoadingStatus, - presentGlobalStatusNotice, - presentLoadingFeedback, - reduceLoadingFeedback, - reduceTrafficSyncFeedback, -} from './loadingFeedback.js'; +import { aggregateLayerLoading, canPresentDeferredStatusNotice, createGlobalStatusNotice, createLoadingFeedbackState, createTrafficSyncFeedbackState, presentGlobalLoadingStatus, reduceLoadingFeedback, reduceTrafficSyncFeedback } from './loadingFeedback.js'; import { setSplitFlapText } from './splitFlap.js'; import { cockpitEntryAllowed } from './contextModePolicy.js'; -import { - resolveCockpitUtilityAnchor, - resolveCockpitUtilityLayout, -} from './cockpitUtilityLayout.js'; -import { - applyCockpitVisionStageIntensities, - captureCockpitVisionBaseline, - COCKPIT_VISION_MODES, - normalizeCockpitVisionMode, -} from './cockpitVisionPolicy.js'; +import { applyCockpitVisionStageIntensities, captureCockpitVisionBaseline, normalizeCockpitVisionMode } from './cockpitVisionPolicy.js'; import { applyContactsDetection, shareCacheNeedsHeal, @@ -141,28 +117,6 @@ import { regionalDistanceM, weatherCodeLabel, } from './data/regionalBrief.js'; -import { - altitudeRulerCurveInset, - altitudeRulerTicks, - bearingBetweenCoordinates, - cockpitAnchorCorrectionStep, - cockpitAltitudeDisplayFt, - cockpitGroundSafeHeight, - cockpitSurfaceWaitExpired, - cockpitUiUpdateDue, - compassDivisions, - formatAltitudeRulerTick, - formatCockpitContextScope, - formatCompassDivision, - formatSpeedRulerTick, - normalizeHeading, - relativeBearing, - resolveCockpitContextReadout, - resolveHudRailLayout, - resolveTrackedAircraftInfo, - slewHeading, - speedRulerTicks, -} from './cockpitMath.js'; /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; @@ -196,49 +150,9 @@ const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1'; /** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ const PANEL_Z_BASE = 100; const PANEL_Z_MAX = 139; -const COCKPIT_HEADING_SLEW_DPS = 28; -const COCKPIT_FORWARD_OFFSET_M = 7; -const COCKPIT_UP_OFFSET_M = 2.6; -const COCKPIT_MIN_GROUND_CLEARANCE_M = 12; -const COCKPIT_VIEW_PITCH_DEG = -4; -const COCKPIT_CAMERA_UPDATE_MS = 50; -const COCKPIT_HUD_UPDATE_MS = 100; -const COCKPIT_CONTEXT_UPDATE_MS = 250; + const COCKPIT_LAYOUT_SETTLE_MS = 240; -// Cockpit's Display/Radio strip hangs off the REC readout it shares the right -// margin with, and never touches the briefing card underneath it. -const COCKPIT_UTILITY_REC_GAP_PX = 12; -const COCKPIT_UTILITY_SIGNAL_GAP_PX = 8; -const COCKPIT_UTILITY_MIN_TOP_PX = 96; -const COCKPIT_UTILITY_MIN_TOP_RATIO = 0.12; -const COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX = 50; -const COCKPIT_GROUND_PROBE_MS = 500; -const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000; -const COCKPIT_BRIEF_ROTATE_MS = 9000; -const COCKPIT_BRIEF_CYCLE_OFF_HELP = 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.'; -const COCKPIT_BRIEF_CYCLE_ON_HELP = 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.'; -const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000; -const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000; -const COCKPIT_BRIEF_PAGES = [ - { - id: 'signals', - kicker: 'LIVE SIGNALS', - subtitle: 'OBSERVED / MAPPED PINGS', - source: 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', - }, - { - id: 'news', - kicker: 'REGIONAL NEWS', - subtitle: 'LATEST LOCATION-MATCHED REPORTING', - source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT', - }, - { - id: 'local', - kicker: 'LOCAL INFO', - subtitle: 'PLACE / CONDITIONS / POSITION', - source: 'OPENSTREETMAP · OPEN-METEO · UTC', - }, -]; + /** * Fixed UI regions that can occupy the left accordion's vertical lane. * Rectangles are filtered at runtime for visibility and horizontal overlap, @@ -270,27 +184,6 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#pp-toggles', '#param-slider-panel', ].join(', '); -/** - * Whether an element currently occupies screen space a layout must respect. - * - * A rect alone is not enough: `visibility: hidden` and `opacity: 0` (how the - * Intel HUD retires as a whole) leave the rect intact, so anything anchoring to - * a HUD readout must walk the ancestors as well. - * - * @param {Element|null|undefined} element - Candidate element. - * @returns {boolean} True when the element is painted and has area. - */ -function isRenderedOnScreen(element) { - if (!element) return false; - for (let node = element; node instanceof Element; node = node.parentElement) { - const style = getComputedStyle(node); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { - return false; - } - } - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -} /** * Fixed UI regions that can occupy the right control lane. Runtime rectangle * filtering keeps the rail clear of whichever HUD variant is currently @@ -350,1634 +243,6 @@ const STYLE_STATUS_LABELS = { * - Intel HUD lifecycle and variant switching. */ -function formatCockpitBriefAge(value) { - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN'; - const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000)); - if (minutes < 60) return `${minutes}M AGO`; - const hours = Math.round(minutes / 60); - return hours < 48 ? `${hours}H AGO` : `${Math.round(hours / 24)}D AGO`; -} - -function formatCockpitWindDirection(value) { - if (!Number.isFinite(value)) return 'DIR UNKNOWN'; - const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - const normalized = ((value % 360) + 360) % 360; - return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`; -} - -function setCockpitRollingValue(element, text, numericValue, { - circularRange = null, - immediate = false, -} = {}) { - if (!element) return; - const nextText = String(text); - const previousText = element.dataset.rollingText; - const previousValue = Number(element.dataset.rollingValue); - const nowMs = performance.now(); - const lastRollMs = Number(element.dataset.rollingAt); - if (!immediate - && previousText !== undefined - && previousText !== nextText - && Number.isFinite(lastRollMs) - && nowMs - lastRollMs < 220) { - return; - } - element.dataset.rollingText = nextText; - element.dataset.rollingAt = String(nowMs); - if (Number.isFinite(numericValue)) element.dataset.rollingValue = String(numericValue); - else delete element.dataset.rollingValue; - element.setAttribute('aria-label', nextText); - - if (immediate || previousText === undefined || previousText === nextText) { - if (previousText !== nextText || !element.querySelector('.cockpit-roll-token')) { - element.replaceChildren(...Array.from(nextText, (character) => { - const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; - token.setAttribute('aria-hidden', 'true'); - token.textContent = character; - return token; - })); - } - return; - } - - let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue) - ? numericValue - previousValue - : 0; - if (Number.isFinite(circularRange) && circularRange > 0) { - const halfRange = circularRange / 2; - if (delta > halfRange) delta -= circularRange; - else if (delta < -halfRange) delta += circularRange; - } - const direction = delta < 0 ? 'down' : 'up'; - const width = Math.max(previousText.length, nextText.length); - const from = previousText.padStart(width, ' '); - const to = nextText.padStart(width, ' '); - const fragment = document.createDocumentFragment(); - - for (let index = 0; index < width; index += 1) { - const previousCharacter = from[index]; - const nextCharacter = to[index]; - const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; - token.setAttribute('aria-hidden', 'true'); - - if (previousCharacter === nextCharacter - || !/\d/.test(previousCharacter) - || !/\d/.test(nextCharacter)) { - token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter; - fragment.append(token); - continue; - } - - token.classList.add('is-rolling', `roll-${direction}`); - const track = document.createElement('span'); - track.className = 'cockpit-roll-track'; - const first = document.createElement('span'); - const second = document.createElement('span'); - first.textContent = direction === 'up' ? previousCharacter : nextCharacter; - second.textContent = direction === 'up' ? nextCharacter : previousCharacter; - track.append(first, second); - token.append(track); - fragment.append(token); - } - - element.replaceChildren(fragment); -} - -class CockpitViewController { - constructor(viewer, { - onVisionChange = null, - onCameraTakeover = null, - isEntryAllowed = null, - onEntered = null, - onExited = null, - getInheritedVisionLabel = null, - restoreTrackingFrame = null, - } = {}) { - this.viewer = viewer; - this.active = false; - this.trackedEntity = null; - this.trackedEntityWasShown = true; - this.heading = null; - this.lastFrameMs = 0; - this.lastCameraUpdateMs = 0; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.lastGroundProbeMs = 0; - this.contextNavigationDeadlineMs = 0; - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastCompassSignature = ''; - this.entry = document.getElementById('cockpit-entry'); - this.tr3bToggle = document.getElementById('tr3b-toggle'); - this._tr3bSignature = null; - this.mapViewButton = document.getElementById('map-view-switch'); - this.resetGlobeButton = document.getElementById('cockpit-reset-globe'); - this.hud = document.getElementById('cockpit-hud'); - this.entryFocusOrigin = null; - this.callsign = document.getElementById('cockpit-callsign'); - this.speed = document.getElementById('cockpit-speed-value'); - this.speedRim = document.getElementById('cockpit-speed-rim'); - this.speedRimValue = document.getElementById('cockpit-speed-rim-value'); - this.speedRimTicks = Array.from(document.querySelectorAll('[data-speed-rim-tick]')); - this.altitude = document.getElementById('cockpit-altitude-value'); - this.altitudeRim = document.getElementById('cockpit-altitude-rim'); - this.altitudeRimValue = document.getElementById('cockpit-altitude-rim-value'); - this.altitudeRimTicks = Array.from(document.querySelectorAll('[data-altitude-rim-tick]')); - this.headingValue = document.getElementById('cockpit-heading-value'); - this.compassTape = document.getElementById('cockpit-compass-tape'); - this.clock = document.getElementById('cockpit-clock'); - this.position = document.getElementById('cockpit-position'); - this.aircraftMeta = document.getElementById('cockpit-aircraft-meta'); - this.route = document.getElementById('cockpit-route'); - this.routeFrom = document.getElementById('cockpit-route-from'); - this.routeTo = document.getElementById('cockpit-route-to'); - this.routeStatus = document.getElementById('cockpit-route-status'); - this.routeDirection = document.getElementById('cockpit-route-direction'); - this.routeDirectionLabel = document.getElementById('cockpit-route-direction-label'); - this.visionPrevious = document.getElementById('cockpit-vision-previous'); - this.visionCurrent = document.getElementById('cockpit-vision-current'); - this.visionCurrentLabel = document.getElementById('cockpit-vision-current-label'); - this.visionNext = document.getElementById('cockpit-vision-next'); - this.visionMode = 'optical'; - this.onVisionChange = onVisionChange; - this.onCameraTakeover = onCameraTakeover; - this.onEntered = onEntered; - this.onExited = onExited; - this.getInheritedVisionLabel = typeof getInheritedVisionLabel === 'function' - ? getInheritedVisionLabel - : () => 'NORMAL'; - this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function' - ? restoreTrackingFrame - : () => false; - this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; - this.context = document.getElementById('cockpit-context'); - this.contextSubject = document.getElementById('cockpit-context-subject'); - this.contextNearestLabel = document.getElementById('cockpit-context-nearest-label'); - this.contextBearing = document.getElementById('cockpit-context-bearing'); - this.contextDistance = document.getElementById('cockpit-context-distance'); - this.contextDirection = document.getElementById('cockpit-context-direction'); - this.contextUncertainty = document.getElementById('cockpit-context-uncertainty'); - this.contextUpdated = document.getElementById('cockpit-context-updated'); - this.contextCohorts = new Map(Array.from(document.querySelectorAll('[data-context-cohort]')) - .map((element) => [element.dataset.contextCohort, element])); - this.contextPrevious = document.getElementById('cockpit-context-previous'); - this.contextNext = document.getElementById('cockpit-context-next'); - this.contextToggle = document.getElementById('cockpit-context-toggle'); - this.weatherToggle = document.getElementById('cockpit-weather-toggle'); - this.weatherState = document.getElementById('cockpit-weather-state'); - this.contextCollapsed = false; - this.signalStream = document.getElementById('cockpit-signal-stream'); - this.signalList = document.getElementById('cockpit-signal-list'); - this.signalToggle = document.getElementById('cockpit-signal-toggle'); - this.briefKicker = document.getElementById('cockpit-brief-kicker'); - this.briefSubtitle = document.getElementById('cockpit-brief-subtitle'); - this.briefPrevious = document.getElementById('cockpit-brief-previous'); - this.briefNext = document.getElementById('cockpit-brief-next'); - this.briefAutoToggle = document.getElementById('cockpit-brief-auto'); - this.briefPosition = document.getElementById('cockpit-brief-position'); - this.briefSource = document.getElementById('cockpit-brief-source'); - this.briefPages = Array.from(document.querySelectorAll('[data-cockpit-brief-page]')); - this.briefTabs = Array.from(document.querySelectorAll('[data-cockpit-brief-index]')); - this.newsStatus = document.getElementById('cockpit-news-status'); - this.newsList = document.getElementById('cockpit-news-list'); - this.localPlace = document.getElementById('cockpit-local-place'); - this.localCoordinates = document.getElementById('cockpit-local-coordinates'); - this.localTemperature = document.getElementById('cockpit-local-temperature'); - this.localWind = document.getElementById('cockpit-local-wind'); - this.localWindDirection = document.getElementById('cockpit-local-wind-direction'); - this.localCondition = document.getElementById('cockpit-local-condition'); - this.localCloud = document.getElementById('cockpit-local-cloud'); - this.localPrecipitation = document.getElementById('cockpit-local-precipitation'); - this.signalCollapsed = false; - this.signalUserCollapsed = false; - this.signalItems = []; - this.signalSignatures = new Map(); - this.briefPageIndex = 0; - this.briefAutoRotateEnabled = false; - this.briefTimer = null; - this.lastAircraftInfo = null; - this.regionalBrief = null; - this.regionalBriefAnchor = null; - this.regionalBriefFetchedAt = 0; - this.regionalBriefAbort = null; - this.regionalBriefRequestToken = 0; - this.regionalBriefSubjectId = null; - this.contextLayoutFrame = null; - this.contextLayoutStamp = null; - this.scratchTarget = new Cesium.Cartesian3(); - this.cockpitAnchor = new Cesium.Cartesian3(); - this.cockpitAnchorValid = false; - this.scratchCamera = new Cesium.Cartesian3(); - this.scratchAdvance = new Cesium.Cartesian3(); - this.scratchCorrection = new Cesium.Cartesian3(); - this.scratchForward = new Cesium.Cartesian3(); - this.scratchHorizontal = new Cesium.Cartesian3(); - this.scratchUp = new Cesium.Cartesian3(); - this.scratchLocal = new Cesium.Cartesian3(); - this.scratchEnu = new Cesium.Matrix4(); - this.scratchAnchorCartographic = new Cesium.Cartographic(); - this.scratchCameraCartographic = new Cesium.Cartographic(); - this.scratchTargetCartographic = new Cesium.Cartographic(); - this._listenerRemovers = []; - - // Camera mutations belong before scene update/culling. Changing the camera - // from preRender makes 3D Tiles discover a new view after traversal and can - // create a self-sustaining refinement loop under a moving cockpit camera. - this._listenerRemovers.push( - viewer.scene.preUpdate.addEventListener(() => this.update()), - viewer.trackedEntityChanged.addEventListener(() => { - if (this.active) this._adoptTrackedEntity(performance.now()); - else this.syncEntry(); - }), - ); - this._listen(this.entry, 'click', () => this.enter()); - this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b()); - this._listen(this.mapViewButton, 'click', () => this.exit()); - this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); - this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1)); - this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1)); - this._listen(this.contextPrevious, 'click', () => this.navigateContext(-1, { origin: 'user' })); - this._listen(this.contextNext, 'click', () => this.navigateContext(1, { origin: 'user' })); - this._listen(this.contextToggle, 'click', () => this.setContextCollapsed(!this.contextCollapsed)); - this._listen(this.weatherToggle, 'click', () => { - const enabled = this.weatherToggle.getAttribute('aria-pressed') !== 'true'; - this.syncWeatherToggle(enabled); - window.dispatchEvent(new CustomEvent('gev:cockpit-weather-toggle', { - detail: { enabled }, - })); - }); - this._listen(window, 'gev:cockpit-weather-state', (event) => { - this.syncWeatherToggle(event?.detail?.enabled !== false); - }); - this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed( - !this.signalCollapsed, - { user: true }, - )); - this._listen(this.signalList, 'click', (event) => { - const target = event.target.closest('button[data-signal-layer][data-signal-id]'); - if (!target) return; - event.preventDefault(); - militaryAwarenessLayer.focusTarget?.( - target.dataset.signalLayer, - target.dataset.signalId, - { origin: 'user' }, - ); - }); - this._listen(this.briefPrevious, 'click', () => this.showBriefPage(this.briefPageIndex - 1, { manual: true })); - this._listen(this.briefNext, 'click', () => this.showBriefPage(this.briefPageIndex + 1, { manual: true })); - this._listen(this.briefAutoToggle, 'click', () => { - this.setBriefAutoRotate(!this.briefAutoRotateEnabled); - }); - this.briefTabs.forEach((button) => this._listen(button, 'click', () => { - this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { manual: true }); - })); - this._listen(document, 'visibilitychange', () => { - if (document.hidden) this.stopBriefRotation(); - else if (this.briefAutoRotateEnabled) this.startBriefRotation(); - }); - this._listen(window, 'resize', () => this.scheduleContextLayout()); - this._listen(document, 'keydown', (event) => this.onKeyDown(event), true); - } - - _listen(target, type, handler, options) { - if (!target?.addEventListener) return; - target.addEventListener(type, handler, options); - this._listenerRemovers.push(() => target.removeEventListener(type, handler, options)); - } - - syncWeatherToggle(enabled) { - if (!this.weatherToggle) return; - const active = !!enabled; - this.weatherToggle.setAttribute('aria-pressed', String(active)); - this.weatherToggle.setAttribute( - 'aria-label', - `${active ? 'Disable' : 'Enable'} cockpit weather effects`, - ); - this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`; - if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF'; - } - - readAircraftInfo() { - // In cockpit mode the controller takes the entity off `viewer.trackedEntity` - // (see update()), so the cockpit's own handle is the tracked identity there. - const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; - return resolveTrackedAircraftInfo({ - civilian: flightsLayer.getTrackedInfo?.() || null, - military: militaryFlightsLayer.getTrackedInfo?.() || null, - trackedId: trackedEntity?.gevTrackedId || '', - }); - } - - dispatchCockpitModeChanged(active, info = null) { - const subjectId = active - ? String(info?.icao24 || '').trim().toLowerCase() || null - : null; - const layerId = active && ['flights', 'military'].includes(info?.layerId) - ? info.layerId - : null; - window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { - detail: { active: active === true, subjectId, layerId }, - })); - } - - /** - * TR-3B Easter egg: flip the tracked contact between its real silhouette and - * the black triangle. The registry owns the session state; the owning layer - * re-derives what renders. - * @returns {boolean} True when a tracked contact was converted/restored. - */ - toggleTrackedTr3b() { - const info = this.readAircraftInfo(); - const icao24 = String(info?.icao24 || '').trim(); - if (!icao24) return false; - toggleTr3b(icao24); - const layer = info.layerId === 'military' ? militaryFlightsLayer : flightsLayer; - layer.refreshTr3b?.(icao24); - this._tr3bSignature = null; // force the chip to repaint on the next sync - this.syncTr3bToggle(info); - return true; - } - - /** - * Show the 🛸 chip whenever a contact is tracked (its own gate — converting - * does not depend on the cockpit entry policy) and mirror the conversion - * state. Change-only DOM writes: this runs on the preUpdate cadence. - * @param {object|null} info Tracked-aircraft descriptor, or null. - */ - syncTr3bToggle(info) { - if (!this.tr3bToggle) return; - const icao24 = String(info?.icao24 || '').trim(); - const converted = !!icao24 && isTr3b(icao24); - const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : ''; - if (this._tr3bSignature === signature) return; - this._tr3bSignature = signature; - this.tr3bToggle.hidden = !icao24; - this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); - this.tr3bToggle.title = converted ? 'Restore real aircraft' : 'Reclassify as TR-3B'; - } - - syncEntry() { - if (this.active) return; - const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); - this.syncTr3bToggle(trackedContact ? info : null); - const available = !!(this.isEntryAllowed() && trackedContact); - // Change-only DOM writes: this runs on a preUpdate cadence, and - // unconditional `hidden` assignments invalidate style/layout every frame - // even when nothing changed. (perf item 9) - if (this._entryAvailable === available) return; - this._entryAvailable = available; - if (this.entry) this.entry.hidden = !available; - if (this.mapViewButton) this.mapViewButton.hidden = true; - if (this.resetGlobeButton) this.resetGlobeButton.hidden = true; - } - - /** - * Navigate Contacts through one Cockpit-owned funnel. A short grace window - * covers feed-refresh handoffs where the old source releases before the new - * source publishes its entity; trackedEntityChanged adopts synchronously as - * soon as the replacement exists, so the normal path does not wait. - */ - navigateContext(direction, options = {}) { - const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; - const wasActive = this.active; - if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; - const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options; - const changed = Boolean(militaryAwarenessLayer?.[method]?.(navigationOptions)); - if (!changed) { - this.contextNavigationDeadlineMs = 0; - return false; - } - if (wasActive) this._adoptTrackedEntity(performance.now()); - return true; - } - - /** Adopt a newly selected aircraft without ever leaving Cockpit. */ - _adoptTrackedEntity(nowMs, suppliedInfo = null) { - const nextEntity = this.viewer.trackedEntity; - if (!this.active || !nextEntity?.position || nextEntity === this.trackedEntity) return false; - const info = suppliedInfo || this.readAircraftInfo(); - if (!info) return false; - if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { - this.trackedEntity.show = this.trackedEntityWasShown; - } - this.trackedEntity = nextEntity; - this.trackedEntityWasShown = nextEntity.show; - nextEntity.show = false; - this.viewer.trackedEntity = undefined; - this.cockpitAnchorValid = false; - this.heading = normalizeHeading(info.track ?? 0); - this.lastFrameMs = nowMs; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.lastCameraUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.dispatchCockpitModeChanged(true, info); - return true; - } - - setVisionMode(mode, { revealParameters = false } = {}) { - const next = normalizeCockpitVisionMode(mode); - this.visionMode = next; - const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase(); - const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' }; - const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' }; - if (this.visionCurrent) { - this.visionCurrent.dataset.cockpitVision = next; - this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`); - this.visionCurrent.title = `Current style: ${names[next]} — click for next`; - } - if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next]; - this.onVisionChange?.(next, this.active, { revealParameters }); - } - - cycleVisionMode(direction = 1) { - const modes = COCKPIT_VISION_MODES; - const currentIndex = Math.max(0, modes.indexOf(this.visionMode)); - const step = direction < 0 ? -1 : 1; - const nextIndex = (currentIndex + step + modes.length) % modes.length; - this.setVisionMode(modes[nextIndex], { revealParameters: true }); - } - - clearPredictiveRoute() { - if (this.routeDirection) this.routeDirection.hidden = true; - } - - onKeyDown(event) { - if (event.repeat || event.isComposing) return; - if (event.key === 'Escape' && this.active) { - // The credit lightbox owns Escape while its Close control or links hold - // focus. Its target handler closes the overlay and restores attribution - // focus; Cockpit must stay active behind it. - if (event.target?.closest?.('.cesium-credit-lightbox')) return; - if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return; - if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return; - if (this.context?.contains(event.target) && !this.contextCollapsed) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setContextCollapsed(true); - if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) { - this.contextToggle?.blur?.(); - } else { - this.contextToggle?.focus({ preventScroll: true }); - } - return; - } - if (this.signalStream?.contains(event.target) && !this.signalCollapsed) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setSignalCollapsed(true, { user: true }); - if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) { - this.signalToggle?.blur?.(); - } else { - this.signalToggle?.focus({ preventScroll: true }); - } - return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - this.exit(); - return; - } - if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; - const key = event.key?.toLowerCase(); - if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { - if (!this.active) { - const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position); - if (!cockpitAttempt) return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - if (!this.active && !this.isEntryAllowed()) return; - const changed = this.active ? this.exit() : this.enter(); - return; - } - } - - enter() { - if (this.active) return false; - if (!this.isEntryAllowed()) return false; - const info = this.readAircraftInfo(); - const entity = this.viewer.trackedEntity; - if (!info || !entity?.position) return false; - this.entryFocusOrigin = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - // Retire deferred navigation before cancelFlight can run its callbacks. - this.onCameraTakeover?.(); - this.viewer.camera.cancelFlight(); - this.trackedEntity = entity; - this.trackedEntityWasShown = entity.show; - entity.show = false; - this.heading = normalizeHeading(info.track ?? 0); - this.lastFrameMs = performance.now(); - this.lastCameraUpdateMs = 0; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.lastGroundProbeMs = 0; - this.surfaceWaitStartedMs = performance.now(); - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastCompassSignature = ''; - this.cockpitAnchorValid = false; - this.lastCameraUpdateMs = 0; - this.active = true; - // Cockpit animates the camera from preUpdate every frame — preUpdate only - // runs on rendered frames, so idle mode would freeze the cockpit solid. - // (perf wave 2) - holdContinuousRender('cockpit'); - this.viewer.trackedEntity = undefined; - this.viewer.scene.screenSpaceCameraController.enableInputs = false; - document.body.classList.add('cockpit-mode'); - // Activation writes entry/quick/map visibility directly, bypassing - // syncEntry's change-only cache — invalidate it so the exit-path - // syncEntry re-applies every write (notably re-hiding mapViewButton). - this._entryAvailable = undefined; - if (this.entry) this.entry.hidden = true; - if (this.tr3bToggle) { - this.tr3bToggle.hidden = true; - this._tr3bSignature = null; - } - if (this.mapViewButton) this.mapViewButton.hidden = false; - if (this.resetGlobeButton) this.resetGlobeButton.hidden = false; - if (this.hud) this.hud.hidden = false; - if (this.signalStream) this.signalStream.hidden = false; - this.hud?.classList.add('signals-active'); - this.signalItems = []; - this.signalSignatures.clear(); - this.showBriefPage(0); - this.startBriefRotation(); - const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; - const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0'); - this.pushCockpitSignal( - 'track', - 'track', - 'TRACK ACQUIRED', - `${trackLabel} · COURSE ${trackHeading}°`, - ); - this.updateHud(info, performance.now(), true); - this.setVisionMode(this.visionMode); - this.scheduleContextLayout(); - this.mapViewButton?.focus({ preventScroll: true }); - this.onEntered?.(); - this.dispatchCockpitModeChanged(true, info); - return true; - } - - exit({ restoreTracking = true } = {}) { - if (!this.active) return false; - const entity = this.trackedEntity; - this.active = false; - releaseContinuousRender('cockpit'); - this.trackedEntity = null; - this.heading = null; - this.cockpitAnchorValid = false; - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.lastCompassSignature = ''; - this.stopBriefRotation(); - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.regionalBriefSubjectId = null; - document.body.classList.remove('cockpit-mode'); - this.onExited?.(); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - if (this.hud) this.hud.hidden = true; - if (this.route) this.route.hidden = true; - this.clearPredictiveRoute(); - this.setVisionMode('optical'); - if (this.signalStream) this.signalStream.hidden = true; - this.hud?.classList.remove('signals-active'); - this.viewer.scene.screenSpaceCameraController.enableInputs = true; - if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; - this.trackedEntityWasShown = true; - this.dispatchCockpitModeChanged(false); - if (restoreTracking && entity && this.viewer.entities.contains(entity)) { - this.viewer.trackedEntity = entity; - this.restoreTrackingFrame(entity); - } - this.syncEntry(); - const restoreTarget = this.entryFocusOrigin === this.entry - ? this.entry - : (this.entry || this.entryFocusOrigin); - this.entryFocusOrigin = null; - if (restoreTarget?.isConnected && !restoreTarget.hidden) { - restoreTarget.focus({ preventScroll: true }); - } - return true; - } - - update() { - if (!this.active) { - // Entry availability changes on a human timescale (tracking start/stop, - // info arriving after a poll) — polling it every rendered frame ran - // readAircraftInfo() + DOM pokes at display rate in plain map mode. - // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still - // fires syncEntry immediately on the events that matter. (perf item 9) - const nowMs = performance.now(); - if (nowMs - (this._lastEntrySyncMs || 0) >= 250) { - this._lastEntrySyncMs = nowMs; - this.syncEntry(); - } - return; - } - - const nowMs = performance.now(); - const info = this.readAircraftInfo(); - - // Adopt a newly selected track before the camera cadence gate. A context - // NEXT/PREV selection can otherwise spend one frame driving the old - // aircraft with the new aircraft's metadata. - this._adoptTrackedEntity(nowMs, info); - if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) { - if (nowMs < this.contextNavigationDeadlineMs) return; - this.exit({ restoreTracking: false }); - return; - } - if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return; - this.lastCameraUpdateMs = nowMs; - - const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget); - if (!target) return; - const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000)); - this.lastFrameMs = nowMs; - if (Number.isFinite(info.track)) { - this.heading = slewHeading( - this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec, - ); - } - - if (!this.cockpitAnchorValid) { - Cesium.Cartesian3.clone(target, this.cockpitAnchor); - this.cockpitAnchorValid = true; - } - - // First-person motion cannot use the delayed feed correction as a raw - // camera destination: a harmless icon re-anchor becomes a whole-world - // surge/reversal in cockpit view. Advance the camera anchor inertially - // from the reported course/speed and converge on the authoritative layer - // display position at a bounded rate. This preserves the layer's required - // 15/30-second interpolation and per-frame cache without exposing its - // sample-boundary corrections to the camera. - const headingRad = Cesium.Math.toRadians(this.heading ?? 0); - const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); - const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; - - if (info.stale) { - // A feed backoff has no authoritative velocity epoch to advance from. - // Hold the cockpit on the exact layer-rendered position so the camera - // cannot coast away while the icon correctly remains fixed. - Cesium.Cartesian3.clone(target, this.cockpitAnchor); - } else { - Cesium.Transforms.eastNorthUpToFixedFrame( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, - ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); - this.scratchLocal.z = 0; - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); - Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - Cesium.Cartesian3.multiplyByScalar( - this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchAdvance, this.cockpitAnchor); - Cesium.Cartesian3.subtract(target, this.cockpitAnchor, this.scratchCorrection); - const correctionDistanceM = Cesium.Cartesian3.magnitude(this.scratchCorrection); - const correctionStepM = cockpitAnchorCorrectionStep(correctionDistanceM, speedMps, dtSec); - if (correctionStepM > 0 && correctionDistanceM > 0) { - Cesium.Cartesian3.multiplyByScalar( - this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor); - } - } - - // The inertial anchor is independent of the layer's render-floor clamp and - // can otherwise coast into a photoreal mesh while a landing contact is - // between fixes. Clamp it against the same mesh-first shared floor used by - // aircraft rendering. For a slow contact whose floor cell is still cold, - // its already-clamped render position is a conservative temporary floor. - const anchorCartographic = Cesium.Cartographic.fromCartesian( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic, - ); - const targetCartographic = Cesium.Cartographic.fromCartesian( - target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic, - ); - let cockpitFloorM = cachedGroundFloor(info.latitude, info.longitude); - if (info.onGround === true) { - const groundPoint = [{ lat: info.latitude, lon: info.longitude }]; - warmGroundFloor(groundPoint); - const meshFloorM = cachedMeshFloor(info.latitude, info.longitude); - if (meshFloorPreferred() && !Number.isFinite(meshFloorM)) { - if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) { - this.lastGroundProbeMs = nowMs; - const viewerCartographic = this.viewer.camera.positionCartographic; - sampleMeshFloorCells(this.viewer.scene, groundPoint, { - excludeObjects: [this.trackedEntity], - viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude), - viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude), - }); - } - cockpitFloorM = cachedMeshFloor(info.latitude, info.longitude); - if (!Number.isFinite(cockpitFloorM)) { - if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; - if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) { - // Keep the already-safe map camera in place while the photoreal - // surface under a parked aircraft is acquired. The bounded wait - // prevents a permanently cold mesh cell from freezing cockpit. - this.surfaceAcquiring = true; - this.surfaceFallback = false; - if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { - this.lastHudUpdateMs = nowMs; - this.updateHud(info, nowMs); - } - return; - } - this.surfaceAcquiring = false; - this.surfaceFallback = true; - } else { - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - } - } - } else { - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - } - if (!Number.isFinite(cockpitFloorM) - && speedMps < 90 - && Number.isFinite(targetCartographic?.height)) { - cockpitFloorM = targetCartographic.height - GROUND_FLOOR_LIFT_M; - } - if (anchorCartographic && Number.isFinite(cockpitFloorM)) { - const minimumAnchorHeightM = cockpitGroundSafeHeight( - anchorCartographic.height, - cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, - ); - if (minimumAnchorHeightM !== anchorCartographic.height) { - anchorCartographic.height = minimumAnchorHeightM; - Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor); - } - } - - // Rebuild the local frame at the stabilized anchor after advancing it. - Cesium.Transforms.eastNorthUpToFixedFrame( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, - ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); - this.scratchLocal.z = 0; - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); - Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - - this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); - this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); - this.scratchLocal.z = Math.sin(pitchRad); - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward); - Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); - - this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); - this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); - this.scratchLocal.z = Math.cos(pitchRad); - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp); - Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp); - - Cesium.Cartesian3.multiplyByScalar( - this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera); - Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget); - Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); - Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget); - Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera); - - // Recheck at the final forward-offset camera coordinate because a taxiing - // aircraft can cross into an adjacent coarse floor cell between updates. - const cameraCartographic = Cesium.Cartographic.fromCartesian( - this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, - ); - if (cameraCartographic) { - const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); - const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); - const cameraFloorM = cachedGroundFloor(cameraLat, cameraLon); - if (Number.isFinite(cameraFloorM)) { - cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM); - } - const safeHeightM = cockpitGroundSafeHeight( - cameraCartographic.height, - cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M, - ); - if (safeHeightM !== cameraCartographic.height) { - cameraCartographic.height = safeHeightM; - Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera); - } - } - - this.viewer.camera.setView({ - destination: this.scratchCamera, - orientation: { direction: this.scratchForward, up: this.scratchUp }, - }); - if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { - this.lastHudUpdateMs = nowMs; - this.updateHud(info, nowMs); - } - } - - updateHud(info, nowMs = performance.now(), forceContext = false) { - this.lastAircraftInfo = info; - const heading = normalizeHeading(this.heading ?? info.track ?? 0); - if (this.callsign) { - this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; - } - const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null; - setCockpitRollingValue( - this.speed, - formatSpeedRulerTick(speedKt), - speedKt, - { immediate: forceContext }, - ); - if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null); - if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); - const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); - this.speedRimTicks.forEach((element, index) => { - const tick = speedTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); - }); - const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround); - if (this.altitude) { - const displayedAltitudeFt = Number.isFinite(altitudeFt) - ? Math.round(altitudeFt) - : null; - setCockpitRollingValue( - this.altitude, - displayedAltitudeFt !== null - ? displayedAltitudeFt.toLocaleString('en-US') - : '-----', - displayedAltitudeFt, - { immediate: forceContext }, - ); - } - if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); - if (this.altitudeRimValue) { - this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); - } - const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length); - this.altitudeRimTicks.forEach((element, index) => { - const tick = altitudeTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); - }); - setCockpitRollingValue( - this.headingValue, - String(Math.round(heading) % 360).padStart(3, '0'), - heading, - { circularRange: 360, immediate: forceContext }, - ); - if (this.compassTape) { - const divisions = compassDivisions(heading); - const signature = divisions.join(','); - if (signature !== this.lastCompassSignature) { - this.lastCompassSignature = signature; - this.compassTape.innerHTML = divisions - .map((division, index) => { - const slot = index - 3; - return `${formatCompassDivision(division)}`; - }) - .join(''); - } - } - if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; - if (this.position) { - const lat = Number.isFinite(info.latitude) - ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--'; - const lon = Number.isFinite(info.longitude) - ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--'; - this.position.textContent = `${lat} · ${lon}`; - } - if (this.aircraftMeta) { - const feedState = this.surfaceAcquiring - ? 'ACQUIRING SURFACE' - : (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK')); - this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; - } - this.updateRoute(info); - if (forceContext - || cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) { - this.lastContextUpdateMs = nowMs; - this.updateLocalPosition(info); - this.maybeRefreshRegionalBrief(info); - this.updateContext(info, heading); - } - if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; - } - - updateRoute(info) { - const origin = info?.route?.origin; - const destination = info?.route?.destination; - const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); - const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN'; - if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); - if (this.routeTo) this.routeTo.textContent = routeLabel(destination); - if (this.routeStatus) { - this.routeStatus.textContent = validDestination - ? 'ARROW · ESTIMATED DIRECTION' - : 'ROUTE DATA UNAVAILABLE'; - } - if (this.route) this.route.hidden = !origin && !destination; - if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) { - this.clearPredictiveRoute(); - return; - } - const destinationBearing = bearingBetweenCoordinates( - info.latitude, - info.longitude, - destination.lat, - destination.lon, - ); - const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0); - if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) { - this.clearPredictiveRoute(); - return; - } - if (this.routeDirection) { - const displayedRelative = Math.max(-120, Math.min(120, relative)); - this.routeDirection.hidden = false; - this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`); - } - if (this.routeDirectionLabel) { - this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`; - } - } - - updateContext(info, heading) { - if (!this.context) return; - const snapshot = militaryAwarenessLayer.getContextSnapshot?.() || null; - const trackedId = info.icao24 || info.id; - const readout = resolveCockpitContextReadout({ snapshot, info }); - if (!readout.visible) { - this.context.hidden = true; - this.hud?.classList.remove('context-active'); - this.contextLayoutStamp = null; - this.pushCockpitSignal( - 'context-status', - 'info', - 'CONTEXT STANDBY', - 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', - ); - return; - } - - this.context.hidden = false; - this.hud?.classList.add('context-active'); - if (this.contextSubject) { - const installationCoverage = snapshot.cohorts - .find((cohort) => cohort.id === 'military-installations')?.coverage; - this.contextSubject.textContent = formatCockpitContextScope( - snapshot.subject.label || trackedId, - snapshot.radiusM, - installationCoverage, - ); - } - // Navigation stays wired in every state — the operator must always be able - // to step off the current contact from the panel that hosts the controls. - if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; - if (this.contextNext) this.contextNext.disabled = !snapshot.navigation?.canNext; - - if (readout.contactLost) { - // The subject left its source. Every number below is measured against a - // position that stopped updating, so hold the last rendered readout and - // say so instead of re-deriving stale geometry as if it were live. - const enteringLost = this.context.dataset.state !== 'lost'; - this.context.dataset.state = 'lost'; - if (this.contextUncertainty) { - this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; - } - // The cue changes the footer's height; re-run layout once on the way in - // rather than every frame the contact stays lost. - if (enteringLost) this.scheduleContextLayout(); - this.pushCockpitSignal( - 'context-status', - 'warning', - `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`, - 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', - ); - return; - } - - let unknownCount = 0; - const nearest = []; - for (const cohort of snapshot.cohorts) { - const element = this.contextCohorts.get(cohort.id); - const value = element?.querySelector('strong'); - if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count); - element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); - if (cohort.count === null) unknownCount += 1; - for (const item of cohort.nearest) nearest.push({ ...item, cohort }); - } - nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity)); - const closest = nearest[0] || null; - const closestLabel = formatAwarenessLabel(closest); - if (this.contextNearestLabel) { - this.contextNearestLabel.textContent = closest - ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE'; - this.contextNearestLabel.setAttribute( - 'aria-label', - closest && closestLabel === '—' - ? `${closest.cohort.label}, Unavailable` - : this.contextNearestLabel.textContent, - ); - } - if (this.contextDistance) { - const distanceM = closest?.distanceM; - this.contextDistance.textContent = Number.isFinite(distanceM) - ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—'; - this.contextDistance.setAttribute( - 'aria-label', - Number.isFinite(distanceM) ? this.contextDistance.textContent : 'Unavailable', - ); - } - - // The arrow and BRG are nose-relative to the tracked aircraft. When the - // subject is a vessel, an installation, or another aircraft, the rest of - // this row is measured from that subject — so the aircraft-frame half is - // dashed rather than presented alongside subject-frame distances as if the - // two shared an origin. - let relative = null; - if (readout.aircraftRelative - && closest?.position && Number.isFinite(info.latitude) && Number.isFinite(info.longitude)) { - const cartographic = Cesium.Cartographic.fromCartesian(closest.position); - const bearing = cartographic ? bearingBetweenCoordinates( - info.latitude, - info.longitude, - Cesium.Math.toDegrees(cartographic.latitude), - Cesium.Math.toDegrees(cartographic.longitude), - ) : null; - relative = relativeBearing(bearing, heading); - } - if (this.contextDirection) { - this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`; - this.contextDirection.classList.toggle('unknown', relative === null); - } - if (this.contextBearing) { - if (relative === null) this.contextBearing.textContent = 'BRG —'; - else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; - else this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`; - } - if (this.contextUncertainty) { - this.contextUncertainty.textContent = unknownCount - ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR` - : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'; - } - if (this.contextUpdated) { - this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) - ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; - } - this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; - this.updateCockpitSignals(snapshot, unknownCount); - if (this.contextLayoutStamp !== snapshot.evaluatedAt) { - this.contextLayoutStamp = snapshot.evaluatedAt; - this.scheduleContextLayout(); - } - } - - scheduleContextLayout() { - const contextVisible = this.context && !this.context.hidden; - const signalVisible = this.signalStream && !this.signalStream.hidden; - if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return; - this.contextLayoutFrame = requestAnimationFrame(() => { - this.contextLayoutFrame = null; - this.syncContextLayout(); - this.syncSignalLayout(); - }); - } - - showBriefPage(index, { manual = false } = {}) { - const count = COCKPIT_BRIEF_PAGES.length; - this.briefPageIndex = ((Number(index) % count) + count) % count; - const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; - this.briefPages.forEach((element) => { - element.hidden = element.dataset.cockpitBriefPage !== page.id; - }); - this.briefTabs.forEach((button) => { - const current = Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; - button.setAttribute('aria-current', current ? 'true' : 'false'); - }); - if (this.briefKicker) { - const indicator = this.briefKicker.querySelector('i'); - this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean)); - } - if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle; - if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`; - if (this.briefSource) this.briefSource.textContent = page.source; - if (this.signalStream) this.signalStream.dataset.briefPage = page.id; - if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); - this.scheduleContextLayout(); - } - - setBriefAutoRotate(enabled) { - this.briefAutoRotateEnabled = Boolean(enabled); - if (this.briefAutoToggle) { - this.briefAutoToggle.setAttribute('aria-pressed', String(this.briefAutoRotateEnabled)); - const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; - this.briefAutoToggle.textContent = label; - const help = this.briefAutoRotateEnabled - ? COCKPIT_BRIEF_CYCLE_ON_HELP - : COCKPIT_BRIEF_CYCLE_OFF_HELP; - this.briefAutoToggle.setAttribute('aria-label', label); - this.briefAutoToggle.title = help; - } - if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); - else this.stopBriefRotation(); - } - - startBriefRotation({ reset = false } = {}) { - if (reset) this.stopBriefRotation(); - if (!this.briefAutoRotateEnabled - || this.briefTimer - || !this.active - || this.signalCollapsed - || document.hidden) return; - this.briefTimer = window.setTimeout(() => { - this.briefTimer = null; - const hasPointer = this.signalStream?.matches(':hover') === true; - const hasFocus = this.signalStream?.contains(document.activeElement) === true; - const isInteracting = hasPointer || hasFocus; - if (!isInteracting) { - this.showBriefPage(this.briefPageIndex + 1); - } - this.startBriefRotation(); - }, COCKPIT_BRIEF_ROTATE_MS); - } - - stopBriefRotation() { - if (this.briefTimer) window.clearTimeout(this.briefTimer); - this.briefTimer = null; - } - - updateLocalPosition(info) { - if (!this.localCoordinates) return; - if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { - this.localCoordinates.textContent = 'POSITION UNAVAILABLE'; - return; - } - const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; - const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`; - this.localCoordinates.textContent = `${lat} · ${lon}`; - } - - maybeRefreshRegionalBrief(info) { - if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; - if (subjectId !== this.regionalBriefSubjectId) { - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.regionalBriefSubjectId = subjectId; - this.regionalBrief = null; - this.regionalBriefAnchor = null; - this.regionalBriefFetchedAt = 0; - } - const point = { latitude: info.latitude, longitude: info.longitude }; - const ageMs = Date.now() - this.regionalBriefFetchedAt; - const distanceM = regionalDistanceM(this.regionalBriefAnchor, point); - if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS - && distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return; - - this.regionalBriefAnchor = point; - this.regionalBriefFetchedAt = Date.now(); - const controller = new AbortController(); - const requestToken = ++this.regionalBriefRequestToken; - this.regionalBriefAbort = controller; - if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info); - fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal }) - .then((payload) => { - if (!this.active - || requestToken !== this.regionalBriefRequestToken - || subjectId !== this.regionalBriefSubjectId) return; - this.regionalBrief = payload; - this.renderRegionalBrief(payload, info); - }) - .catch((error) => { - if (error?.name !== 'AbortError' - && this.active - && requestToken === this.regionalBriefRequestToken - && subjectId === this.regionalBriefSubjectId) { - this.renderRegionalBriefStatus('unavailable', info); - } - }) - .finally(() => { - if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null; - }); - } - - renderRegionalBriefStatus(status, info) { - if (this.newsStatus) { - this.newsStatus.hidden = false; - this.newsStatus.dataset.state = status; - this.newsStatus.textContent = status === 'loading' - ? 'ACQUIRING REGIONAL NEWS' - : 'REGIONAL NEWS UNAVAILABLE'; - } - if (status === 'unavailable') this.newsList?.replaceChildren(); - if (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION'; - if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE'; - this.updateLocalPosition(info); - } - - renderRegionalBrief(payload, info) { - const articles = Array.isArray(payload?.articles) ? payload.articles : []; - if (this.newsStatus) { - this.newsStatus.hidden = articles.length > 0; - this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; - this.newsStatus.textContent = payload?.newsStatus === 'empty' - ? 'NO RECENT LOCATION MATCHES' - : 'REGIONAL NEWS UNAVAILABLE'; - } - if (this.newsList) { - this.newsList.replaceChildren(...articles.slice(0, 4).map((article) => { - const entry = document.createElement('li'); - const link = document.createElement('a'); - link.href = article.url; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - const title = document.createElement('strong'); - title.textContent = article.title; - const metadata = document.createElement('span'); - metadata.textContent = `${article.domain || 'SOURCE'} · ${formatCockpitBriefAge(article.publishedAt)}`; - link.append(title, metadata); - entry.append(link); - return entry; - })); - } - - const placeLabel = payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE'; - if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); - this.updateLocalPosition(info); - const weather = payload?.weather; - if (this.localTemperature) { - this.localTemperature.textContent = Number.isFinite(weather?.temperatureC) - ? `${Math.round(weather.temperatureC)}°C` : '—'; - } - if (this.localWind) { - this.localWind.textContent = Number.isFinite(weather?.windKph) - ? `${Math.round(weather.windKph)} KM/H` : '—'; - } - if (this.localWindDirection) { - this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg); - } - if (this.localCondition) this.localCondition.textContent = weatherCodeLabel(weather?.weatherCode); - if (this.localCloud) { - this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) - ? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN'; - } - if (this.localPrecipitation) { - this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm) - ? weather.precipitationMm.toFixed(1) : '—'; - } - if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; - if (this.briefPageIndex === 1 && this.briefSource) { - this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`; - } - this.scheduleContextLayout(); - } - - renderCockpitSignals() { - if (!this.signalList) return; - const existing = [...this.signalList.children]; - const focusedEntry = existing.find((entry) => entry.contains(document.activeElement)); - const entriesByKey = new Map(); - for (const entry of existing) { - const key = entry.dataset.signalKey; - if (!entriesByKey.has(key)) entriesByKey.set(key, []); - entriesByKey.get(key).push(entry); - } - const setText = (element, value) => { - if (element.textContent !== value) element.textContent = value; - }; - const entries = this.signalItems.map((item) => { - // Identity includes the action target: a reused status key must never - // silently turn a focused flight button into a different selection. - const key = JSON.stringify([ - item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''), - ]); - let entry = entriesByKey.get(key)?.shift(); - if (!entry) { - entry = document.createElement('li'); - entry.dataset.signalKey = key; - const time = document.createElement('time'); - const body = document.createElement('div'); - const heading = document.createElement(item.target ? 'button' : 'strong'); - if (item.target) { - heading.type = 'button'; - heading.className = 'cockpit-signal-target'; - const label = document.createElement('span'); - label.className = 'cockpit-signal-target-label'; - const rule = document.createElement('span'); - rule.className = 'cockpit-signal-target-rule'; - rule.setAttribute('aria-hidden', 'true'); - const chevron = document.createElement('span'); - chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; - chevron.setAttribute('aria-hidden', 'true'); - chevron.textContent = 'chevron_right'; - heading.append(label, rule, chevron); - } - body.append(heading, document.createElement('span')); - entry.append(time, body); - } - const [time, body] = entry.children; - const [heading, copy] = body.children; - const className = item.target ? `${item.tone} actionable` : item.tone; - if (entry.className !== className) entry.className = className; - setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'); - if (item.target) { - heading.dataset.signalLayer = item.target.layerId; - heading.dataset.signalId = item.target.id; - const label = `Select flight ${item.title}`; - if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label); - setText(heading.children[0], item.title); - } else { - setText(heading, item.title); - } - setText(copy, item.detail); - return entry; - }); - const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null; - if (focusedEntry && !retainedFocus) { - // A departed contact cannot remain selectable. Continue from the stable - // briefing footer instead of dropping keyboard traversal to the page top. - (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true }); - } - for (const entry of existing) if (!entries.includes(entry)) entry.remove(); - - // Ordinary insertBefore moves disconnect an element and lose its focus. - // Reorder the other rows around the focused row, which stays connected. - const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1; - if (retainedFocus) { - let anchor = retainedFocus; - for (let index = focusIndex - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor); - anchor = entry; - } - } - let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild; - for (let index = focusIndex + 1; index < entries.length; index += 1) { - const entry = entries[index]; - if (entry === anchor) anchor = anchor.nextElementSibling; - else this.signalList.insertBefore(entry, anchor); - } - this.scheduleContextLayout(); - } - - pushCockpitSignal(key, tone, title, detail, target = null) { - if (!this.signalList || !title || !detail) return; - const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; - if (this.signalSignatures.get(key) === signature) return; - this.signalSignatures.set(key, signature); - this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() }); - this.signalItems = this.signalItems.slice(0, 5); - this.renderCockpitSignals(); - } - - updateCockpitSignals(snapshot, unknownCount) { - const previous = new Map(this.signalItems.map((item) => [item.key, item])); - const contacts = []; - const subject = snapshot.subject; - if (['flights', 'military'].includes(subject?.layerId) && subject?.id) { - contacts.push({ - key: `flight:${subject.layerId}:${subject.id}`, - tone: 'track', - title: subject.label || subject.id, - detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`, - target: { layerId: subject.layerId, id: String(subject.id) }, - distanceM: -1, - }); - } - for (const cohort of snapshot.cohorts) { - if (!['flights', 'military'].includes(cohort.id)) continue; - for (const item of cohort.nearest) { - const id = item.icao24 || item.id; - if (!id) continue; - contacts.push({ - key: `flight:${cohort.id}:${id}`, - tone: cohort.id === 'military' ? 'nearby military' : 'nearby', - // `id` above is IDENTITY (the ICAO hex). Display must not reuse it: - // the row's own `id` already carries the layer's label convention - // (callsign → registration → hex), so a callsign-less enriched - // contact reads as its registration here too. Same helper the - // Context panel's nearest list uses. - title: formatAwarenessLabel(item), - detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${ - Number.isFinite(item.distanceM) - ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` - : 'DISTANCE UNKNOWN' - }`, - target: { layerId: cohort.id, id: String(id) }, - distanceM: item.distanceM ?? Infinity, - }); - } - } - contacts.sort((a, b) => a.distanceM - b.distanceM); - const nextItems = contacts.slice(0, 5).map((item) => ({ - ...item, - timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), - })); - if (unknownCount) { - const sources = snapshot.cohorts - .filter((cohort) => cohort.count === null) - .map((cohort) => cohort.source) - .join(' · '); - nextItems.splice(4, Math.max(0, nextItems.length - 4), { - key: 'input-status', - tone: 'warning', - title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`, - detail: sources || 'SOURCE STATUS UNAVAILABLE', - target: null, - timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(), - }); - } - this.signalItems = nextItems; - this.signalSignatures.clear(); - this.renderCockpitSignals(); - } - - setContextCollapsed(collapsed) { - const wasCollapsed = this.contextCollapsed; - this.contextCollapsed = Boolean(collapsed); - if (this.context) this.context.dataset.collapsed = String(this.contextCollapsed); - if (this.contextToggle) { - const expanded = !this.contextCollapsed; - this.contextToggle.setAttribute('aria-expanded', String(expanded)); - this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`); - this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`; - const icon = this.contextToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; - } - if (this.active && wasCollapsed && !this.contextCollapsed) { - window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); - } - this.scheduleContextLayout(); - } - - setSignalCollapsed(collapsed, { user = false } = {}) { - const wasCollapsed = this.signalCollapsed; - this.signalCollapsed = Boolean(collapsed); - if (user) this.signalUserCollapsed = this.signalCollapsed; - if (this.signalStream) this.signalStream.dataset.collapsed = String(this.signalCollapsed); - if (this.signalToggle) { - const expanded = !this.signalCollapsed; - this.signalToggle.setAttribute('aria-expanded', String(expanded)); - this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`); - this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`; - const icon = this.signalToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; - } - if (this.signalCollapsed) this.stopBriefRotation(); - else this.startBriefRotation({ reset: true }); - if (this.active && wasCollapsed && !this.signalCollapsed) { - window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded')); - } - this.scheduleContextLayout(); - } - - syncContextLayout() { - if (!this.context || this.context.hidden) return; - if (window.matchMedia('(max-width: 760px)').matches) { - this.context.dataset.layoutMode = 'compact-bottom'; - this.context.style.removeProperty('--cockpit-context-left'); - this.context.style.removeProperty('--cockpit-context-top'); - this.context.style.removeProperty('--cockpit-context-max-height'); - return; - } - - const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); - this.context.dataset.layoutMode = 'bottom-left'; - this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`); - this.context.style.removeProperty('--cockpit-context-top'); - this.context.style.removeProperty('--cockpit-context-max-height'); - } - - syncSignalLayout() { - if (!this.signalStream || this.signalStream.hidden) return; - const utilityControls = document.getElementById('cockpit-utility-controls'); - if (window.matchMedia('(max-width: 760px)').matches) { - this.signalStream.dataset.layoutMode = 'compact-top'; - utilityControls?.classList.remove('layout-primary-only'); - utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { - const hiddenSibling = Boolean( - utilityControls.querySelector('.cockpit-utility-control.is-expanded') - && !control.classList.contains('is-expanded') - ); - control.setAttribute('aria-hidden', String(hiddenSibling)); - }); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height'); - this.signalStream.style.removeProperty('--cockpit-signal-right'); - this.signalStream.style.removeProperty('--cockpit-signal-top'); - this.signalStream.style.removeProperty('--cockpit-signal-max-height'); - return; - } - - const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); - this.signalStream.dataset.layoutMode = 'bottom-right'; - this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`); - this.signalStream.style.removeProperty('--cockpit-signal-top'); - this.signalStream.style.removeProperty('--cockpit-signal-max-height'); - const signalBounds = this.signalStream.getBoundingClientRect(); - const utilityBounds = utilityControls?.getBoundingClientRect(); - if (utilityBounds) { - const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded'); - const collapsedControl = utilityControls?.querySelector( - '.cockpit-utility-control:not(.is-expanded)', - ); - const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher'); - const expandedHeight = expandedControl - ? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height) - : 0; - const collapsedHeight = collapsedLauncher - ? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight) - : 0; - // Cockpit owns this anchor outright. The strip used to inherit the left - // accordion's committed top, which is solved against left-lane obstacles - // and dropped the strip straight through the briefing card below it. - // The readout only anchors the strip while it is genuinely on screen: - // the Minimal variant drops it with `display:none`, but HUD Off hides the - // whole Intel HUD with `visibility`/`opacity`, which keeps its rect. - const recReadout = document.querySelector('#intel-hud .hud-top-right'); - const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null; - const utilityAnchor = resolveCockpitUtilityAnchor({ - recBottom: recBounds ? recBounds.bottom : 0, - signalTop: signalBounds.top, - stripHeight: utilityBounds.height, - viewportHeight: window.innerHeight, - collapsedHeight, - recGap: COCKPIT_UTILITY_REC_GAP_PX, - signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX, - minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX, - minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, - }); - const availableHeight = utilityAnchor.maxHeight; - this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`); - this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`); - const utilityLayout = expandedControl && collapsedControl - ? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight }) - : { primaryOnly: false, expandedMaxHeight: availableHeight }; - utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly); - this.hud?.style.setProperty( - '--cockpit-utility-expanded-max-height', - `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, - ); - utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { - const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl; - control.setAttribute('aria-hidden', String(hiddenSibling)); - if (hiddenSibling && control.contains(document.activeElement)) { - expandedControl?.querySelector('.cockpit-utility-glyph')?.focus({ preventScroll: true }); - } - }); - } - } - - dispose() { - this.exit({ restoreTracking: false }); - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.stopBriefRotation(); - if (this.contextLayoutFrame !== null) cancelAnimationFrame(this.contextLayoutFrame); - this.contextLayoutFrame = null; - for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.(); - } -} - export class StyleManager { /** * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. @@ -2113,8 +378,7 @@ export class StyleManager { this._cockpitUtilityControls = document.getElementById('cockpit-utility-controls'); this._cockpitDisplayToggleBtn = document.getElementById('cockpit-display-toggle-btn'); this._cockpitDisplayPanel = document.getElementById('cockpit-display-panel'); - this._cockpitDisplayPortalRecords = []; - this._cockpitDisplayPortalActive = false; + this._cockpitDisplayPortal = null; this._cockpitDisplayModeHandler = null; this._cockpitRadioToggleBtn = document.getElementById('cockpit-radio-toggle-btn'); this._cockpitRadioPanel = document.getElementById('cockpit-radio-panel'); @@ -2225,6 +489,25 @@ export class StyleManager { /** Pre-Contacts detection state, restored on deactivation (see _syncContactsDetection). */ this._contactsDetectionRestore = null; this.cockpitView = new CockpitViewController(viewer, { + services: { + flightsLayer, + militaryFlightsLayer, + isTr3b, + toggleTr3b, + militaryAwarenessLayer, + formatAwarenessLabel, + cachedGroundFloor, + cachedMeshFloor, + GROUND_FLOOR_LIFT_M, + meshFloorPreferred, + warmGroundFloor, + sampleMeshFloorCells, + holdContinuousRender, + releaseContinuousRender, + fetchRegionalBrief, + regionalDistanceM, + weatherCodeLabel, + }, onVisionChange: (mode, active, options) => this._setCockpitVision(mode, active, options), onCameraTakeover: () => this._stampNavigation({ cancelPendingSelection: false }), getInheritedVisionLabel: () => ( @@ -6691,40 +4974,21 @@ export class StyleManager { * @returns {void} */ _initCockpitDisplayPortal() { - const definitions = [ - ['hud', this._hudBtn?.closest('.pp-toggle-group')], - ['detection', this._detectionBtn?.closest('.pp-toggle-group')], - ['parameters', this._sliderPanel], - ['models3d', this._models3dBtn?.closest('.pp-toggle-group')], - ]; - this._cockpitDisplayPortalRecords = definitions.flatMap(([name, group]) => { - const slot = this._cockpitDisplayPanel?.querySelector( - `[data-cockpit-display-slot="${name}"]`, - ); - if (!group || !slot || !group.parentNode) return []; - const anchor = document.createComment(`cockpit-display-home:${name}`); - group.before(anchor); - return [{ name, group, slot, anchor }]; + this._cockpitDisplayPortal?.destroy(); + this._cockpitDisplayPortal = new CockpitDisplayPortal({ + standardPanel: this._ppToggles, + cockpitPanel: this._cockpitDisplayPanel, + groups: [ + ['hud', this._hudBtn?.closest('.pp-toggle-group')], + ['detection', this._detectionBtn?.closest('.pp-toggle-group')], + ['parameters', this._sliderPanel], + ['models3d', this._models3dBtn?.closest('.pp-toggle-group')], + ], + layout: () => { + this._layoutRightPanels(); + this.cockpitView?.scheduleContextLayout(); + }, }); - this._standardDisplayScrollTop = this._ppToggles?.scrollTop || 0; - this._cockpitDisplayScrollTop = this._cockpitDisplayPanel?.scrollTop || 0; - this._standardDisplayScrollHandler = () => { - if (!this._cockpitDisplayPortalActive) { - this._standardDisplayScrollTop = this._ppToggles?.scrollTop || 0; - } - }; - this._cockpitDisplayScrollHandler = () => { - if (this._cockpitDisplayPortalActive) { - this._cockpitDisplayScrollTop = this._cockpitDisplayPanel?.scrollTop || 0; - } - }; - this._ppToggles?.addEventListener('scroll', this._standardDisplayScrollHandler, { passive: true }); - this._cockpitDisplayPanel?.addEventListener('scroll', this._cockpitDisplayScrollHandler, { passive: true }); - this._cockpitDisplayModeHandler = (event) => { - this._setCockpitDisplayPortalActive(event?.detail?.active === true); - }; - window.addEventListener('gev:cockpit-mode-changed', this._cockpitDisplayModeHandler); - this._setCockpitDisplayPortalActive(document.body.classList.contains('cockpit-mode')); } /** @@ -6733,49 +4997,10 @@ export class StyleManager { * @param {boolean} active Whether Cockpit owns the Display control groups. * @returns {void} */ - _setCockpitDisplayPortalActive(active) { - const nextActive = active === true; - if (this._cockpitDisplayPortalActive === nextActive) return; - const focusedRecord = this._cockpitDisplayPortalRecords.find((record) => ( - record.group.contains(document.activeElement) - )); - const focusedElement = focusedRecord ? document.activeElement : null; - this._displayPortalScrollRestoreOwner = nextActive ? 'cockpit' : 'standard'; - this._cockpitDisplayPortalActive = nextActive; - for (const record of this._cockpitDisplayPortalRecords) { - if (nextActive) { - record.slot.append(record.group); - } else if (record.anchor.parentNode) { - record.anchor.after(record.group); - } - } - this._cockpitDisplayPanel?.classList.toggle('uses-shared-display-controls', nextActive); - requestAnimationFrame(() => { - if (nextActive && this._cockpitDisplayPanel) { - this._cockpitDisplayPanel.scrollTop = this._cockpitDisplayScrollTop; - } - if (!nextActive && this._ppToggles) { - this._ppToggles.scrollTop = this._standardDisplayScrollTop; - } - focusedElement?.focus?.({ preventScroll: true }); - // Portal movement can trigger one more adaptive-layout/clamp pass after - // the first frame. Reapply the owning surface's saved position once the - // new layout has fully settled. - requestAnimationFrame(() => { - if (nextActive && this._cockpitDisplayPanel) { - this._cockpitDisplayPanel.scrollTop = this._cockpitDisplayScrollTop; - } - if (!nextActive && this._ppToggles) { - this._ppToggles.scrollTop = this._standardDisplayScrollTop; - } - if (this._displayPortalScrollRestoreOwner === (nextActive ? 'cockpit' : 'standard')) { - this._displayPortalScrollRestoreOwner = null; - } - }); - }); - this._layoutRightPanels(); - this.cockpitView?.scheduleContextLayout(); - } + _setCockpitDisplayPortalActive(active) { this._cockpitDisplayPortal?.setActive(active); } + get _cockpitDisplayPortalActive() { return this._cockpitDisplayPortal?.active ?? false; } + get _displayPortalScrollRestoreOwner() { return this._cockpitDisplayPortal?.restoreOwner ?? null; } + get _standardDisplayScrollTop() { return this._cockpitDisplayPortal?.standardScrollTop ?? 0; } /** * Syncs the HUD toggle button active class and HUD layout row visibility @@ -6968,17 +5193,8 @@ export class StyleManager { this._irFogWasEnabled = null; } this.cockpitView?.dispose(); - if (this._cockpitDisplayModeHandler) { - window.removeEventListener('gev:cockpit-mode-changed', this._cockpitDisplayModeHandler); - this._cockpitDisplayModeHandler = null; - } - this._setCockpitDisplayPortalActive(false); - this._ppToggles?.removeEventListener('scroll', this._standardDisplayScrollHandler); - this._cockpitDisplayPanel?.removeEventListener('scroll', this._cockpitDisplayScrollHandler); - this._standardDisplayScrollHandler = null; - this._cockpitDisplayScrollHandler = null; - for (const record of this._cockpitDisplayPortalRecords) record.anchor.remove(); - this._cockpitDisplayPortalRecords = []; + this._cockpitDisplayPortal?.destroy(); + this._cockpitDisplayPortal = null; this._contextControls.disconnect(); this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; diff --git a/src/ui/cockpit.js b/src/ui/cockpit.js new file mode 100644 index 000000000..1813dd24e --- /dev/null +++ b/src/ui/cockpit.js @@ -0,0 +1,2 @@ +export { CockpitViewController } from './cockpitController.js'; +export { CockpitDisplayPortal } from './cockpitDisplayPortal.js'; diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js new file mode 100644 index 000000000..ae777e377 --- /dev/null +++ b/src/ui/cockpitBriefing.js @@ -0,0 +1,192 @@ +/** Regional briefing requests, pages and rotation for the Cockpit controller. */ +import { COCKPIT_BRIEF_ROTATE_MS, COCKPIT_BRIEF_CYCLE_OFF_HELP, COCKPIT_BRIEF_CYCLE_ON_HELP, COCKPIT_REGIONAL_REFRESH_MS, COCKPIT_REGIONAL_REFRESH_DISTANCE_M, COCKPIT_BRIEF_PAGES, formatCockpitBriefAge, formatCockpitWindDirection } from './cockpitPresentation.js'; + +export function showBriefPage(index, { manual = false } = {}) { + const count = COCKPIT_BRIEF_PAGES.length; + this.briefPageIndex = ((Number(index) % count) + count) % count; + const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; + this.briefPages.forEach((element) => { + element.hidden = element.dataset.cockpitBriefPage !== page.id; + }); + this.briefTabs.forEach((button) => { + const current = Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; + button.setAttribute('aria-current', current ? 'true' : 'false'); + }); + if (this.briefKicker) { + const indicator = this.briefKicker.querySelector('i'); + this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean)); + } + if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle; + if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`; + if (this.briefSource) this.briefSource.textContent = page.source; + if (this.signalStream) this.signalStream.dataset.briefPage = page.id; + if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); + this.scheduleContextLayout(); + } + +export function setBriefAutoRotate(enabled) { + this.briefAutoRotateEnabled = Boolean(enabled); + if (this.briefAutoToggle) { + this.briefAutoToggle.setAttribute('aria-pressed', String(this.briefAutoRotateEnabled)); + const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; + this.briefAutoToggle.textContent = label; + const help = this.briefAutoRotateEnabled + ? COCKPIT_BRIEF_CYCLE_ON_HELP + : COCKPIT_BRIEF_CYCLE_OFF_HELP; + this.briefAutoToggle.setAttribute('aria-label', label); + this.briefAutoToggle.title = help; + } + if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); + else this.stopBriefRotation(); + } + +export function startBriefRotation({ reset = false } = {}) { + if (reset) this.stopBriefRotation(); + if (!this.briefAutoRotateEnabled + || this.briefTimer + || !this.active + || this.signalCollapsed + || document.hidden) return; + this.briefTimer = window.setTimeout(() => { + this.briefTimer = null; + const hasPointer = this.signalStream?.matches(':hover') === true; + const hasFocus = this.signalStream?.contains(document.activeElement) === true; + const isInteracting = hasPointer || hasFocus; + if (!isInteracting) { + this.showBriefPage(this.briefPageIndex + 1); + } + this.startBriefRotation(); + }, COCKPIT_BRIEF_ROTATE_MS); + } + +export function stopBriefRotation() { + if (this.briefTimer) window.clearTimeout(this.briefTimer); + this.briefTimer = null; + } + +export function updateLocalPosition(info) { + if (!this.localCoordinates) return; + if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { + this.localCoordinates.textContent = 'POSITION UNAVAILABLE'; + return; + } + const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; + const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`; + this.localCoordinates.textContent = `${lat} · ${lon}`; + } + +export function maybeRefreshRegionalBrief(info) { + if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return; + const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; + if (subjectId !== this.regionalBriefSubjectId) { + this.regionalBriefAbort?.abort(); + this.regionalBriefAbort = null; + this.regionalBriefRequestToken += 1; + this.regionalBriefSubjectId = subjectId; + this.regionalBrief = null; + this.regionalBriefAnchor = null; + this.regionalBriefFetchedAt = 0; + } + const point = { latitude: info.latitude, longitude: info.longitude }; + const ageMs = Date.now() - this.regionalBriefFetchedAt; + const distanceM = this.services.regionalDistanceM(this.regionalBriefAnchor, point); + if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS + && distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return; + + this.regionalBriefAnchor = point; + this.regionalBriefFetchedAt = Date.now(); + const controller = new AbortController(); + const requestToken = ++this.regionalBriefRequestToken; + this.regionalBriefAbort = controller; + if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info); + this.services.fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal }) + .then((payload) => { + if (!this.active + || requestToken !== this.regionalBriefRequestToken + || subjectId !== this.regionalBriefSubjectId) return; + this.regionalBrief = payload; + this.renderRegionalBrief(payload, info); + }) + .catch((error) => { + if (error?.name !== 'AbortError' + && this.active + && requestToken === this.regionalBriefRequestToken + && subjectId === this.regionalBriefSubjectId) { + this.renderRegionalBriefStatus('unavailable', info); + } + }) + .finally(() => { + if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null; + }); + } + +export function renderRegionalBriefStatus(status, info) { + if (this.newsStatus) { + this.newsStatus.hidden = false; + this.newsStatus.dataset.state = status; + this.newsStatus.textContent = status === 'loading' + ? 'ACQUIRING REGIONAL NEWS' + : 'REGIONAL NEWS UNAVAILABLE'; + } + if (status === 'unavailable') this.newsList?.replaceChildren(); + if (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION'; + if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE'; + this.updateLocalPosition(info); + } + +export function renderRegionalBrief(payload, info) { + const articles = Array.isArray(payload?.articles) ? payload.articles : []; + if (this.newsStatus) { + this.newsStatus.hidden = articles.length > 0; + this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; + this.newsStatus.textContent = payload?.newsStatus === 'empty' + ? 'NO RECENT LOCATION MATCHES' + : 'REGIONAL NEWS UNAVAILABLE'; + } + if (this.newsList) { + this.newsList.replaceChildren(...articles.slice(0, 4).map((article) => { + const entry = document.createElement('li'); + const link = document.createElement('a'); + link.href = article.url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + const title = document.createElement('strong'); + title.textContent = article.title; + const metadata = document.createElement('span'); + metadata.textContent = `${article.domain || 'SOURCE'} · ${formatCockpitBriefAge(article.publishedAt)}`; + link.append(title, metadata); + entry.append(link); + return entry; + })); + } + + const placeLabel = payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE'; + if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); + this.updateLocalPosition(info); + const weather = payload?.weather; + if (this.localTemperature) { + this.localTemperature.textContent = Number.isFinite(weather?.temperatureC) + ? `${Math.round(weather.temperatureC)}°C` : '—'; + } + if (this.localWind) { + this.localWind.textContent = Number.isFinite(weather?.windKph) + ? `${Math.round(weather.windKph)} KM/H` : '—'; + } + if (this.localWindDirection) { + this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg); + } + if (this.localCondition) this.localCondition.textContent = this.services.weatherCodeLabel(weather?.weatherCode); + if (this.localCloud) { + this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) + ? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN'; + } + if (this.localPrecipitation) { + this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm) + ? weather.precipitationMm.toFixed(1) : '—'; + } + if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; + if (this.briefPageIndex === 1 && this.briefSource) { + this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`; + } + this.scheduleContextLayout(); + } diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js new file mode 100644 index 000000000..8071fd2b3 --- /dev/null +++ b/src/ui/cockpitCamera.js @@ -0,0 +1,225 @@ +/** Camera placement, ground acquisition and motion correction for the Cockpit controller. */ +import * as Cesium from 'cesium'; +import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, slewHeading } from '../cockpitMath.js'; +import { COCKPIT_HEADING_SLEW_DPS, COCKPIT_FORWARD_OFFSET_M, COCKPIT_UP_OFFSET_M, COCKPIT_MIN_GROUND_CLEARANCE_M, COCKPIT_VIEW_PITCH_DEG, COCKPIT_CAMERA_UPDATE_MS, COCKPIT_HUD_UPDATE_MS, COCKPIT_GROUND_PROBE_MS, COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js'; + +export function update() { + if (this.destroyed) return false; + if (!this.active) { + // Entry availability changes on a human timescale (tracking start/stop, + // info arriving after a poll) — polling it every rendered frame ran + // readAircraftInfo() + DOM pokes at display rate in plain map mode. + // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still + // fires syncEntry immediately on the events that matter. (perf item 9) + const nowMs = performance.now(); + if (nowMs - (this._lastEntrySyncMs || 0) >= 250) { + this._lastEntrySyncMs = nowMs; + this.syncEntry(); + } + return; + } + + const nowMs = performance.now(); + const info = this.readAircraftInfo(); + + // Adopt a newly selected track before the camera cadence gate. A context + // NEXT/PREV selection can otherwise spend one frame driving the old + // aircraft with the new aircraft's metadata. + this._adoptTrackedEntity(nowMs, info); + if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) { + if (nowMs < this.contextNavigationDeadlineMs) return; + this.exit({ restoreTracking: false }); + return; + } + if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return; + this.lastCameraUpdateMs = nowMs; + + const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget); + if (!target) return; + const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000)); + this.lastFrameMs = nowMs; + if (Number.isFinite(info.track)) { + this.heading = slewHeading( + this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec, + ); + } + + if (!this.cockpitAnchorValid) { + Cesium.Cartesian3.clone(target, this.cockpitAnchor); + this.cockpitAnchorValid = true; + } + + // First-person motion cannot use the delayed feed correction as a raw + // camera destination: a harmless icon re-anchor becomes a whole-world + // surge/reversal in cockpit view. Advance the camera anchor inertially + // from the reported course/speed and converge on the authoritative layer + // display position at a bounded rate. This preserves the layer's required + // 15/30-second interpolation and per-frame cache without exposing its + // sample-boundary corrections to the camera. + const headingRad = Cesium.Math.toRadians(this.heading ?? 0); + const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); + const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; + + if (info.stale) { + // A feed backoff has no authoritative velocity epoch to advance from. + // Hold the cockpit on the exact layer-rendered position so the camera + // cannot coast away while the icon correctly remains fixed. + Cesium.Cartesian3.clone(target, this.cockpitAnchor); + } else { + Cesium.Transforms.eastNorthUpToFixedFrame( + this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, + ); + this.scratchLocal.x = Math.sin(headingRad); + this.scratchLocal.y = Math.cos(headingRad); + this.scratchLocal.z = 0; + Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); + Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); + Cesium.Cartesian3.multiplyByScalar( + this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance, + ); + Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchAdvance, this.cockpitAnchor); + Cesium.Cartesian3.subtract(target, this.cockpitAnchor, this.scratchCorrection); + const correctionDistanceM = Cesium.Cartesian3.magnitude(this.scratchCorrection); + const correctionStepM = cockpitAnchorCorrectionStep(correctionDistanceM, speedMps, dtSec); + if (correctionStepM > 0 && correctionDistanceM > 0) { + Cesium.Cartesian3.multiplyByScalar( + this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection, + ); + Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor); + } + } + + // The inertial anchor is independent of the layer's render-floor clamp and + // can otherwise coast into a photoreal mesh while a landing contact is + // between fixes. Clamp it against the same mesh-first shared floor used by + // aircraft rendering. For a slow contact whose floor cell is still cold, + // its already-clamped render position is a conservative temporary floor. + const anchorCartographic = Cesium.Cartographic.fromCartesian( + this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic, + ); + const targetCartographic = Cesium.Cartographic.fromCartesian( + target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic, + ); + let cockpitFloorM = this.services.cachedGroundFloor(info.latitude, info.longitude); + if (info.onGround === true) { + const groundPoint = [{ lat: info.latitude, lon: info.longitude }]; + this.services.warmGroundFloor(groundPoint); + const meshFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude); + if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) { + if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) { + this.lastGroundProbeMs = nowMs; + const viewerCartographic = this.viewer.camera.positionCartographic; + this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, { + excludeObjects: [this.trackedEntity], + viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude), + viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude), + }); + } + cockpitFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude); + if (!Number.isFinite(cockpitFloorM)) { + if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; + if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) { + // Keep the already-safe map camera in place while the photoreal + // surface under a parked aircraft is acquired. The bounded wait + // prevents a permanently cold mesh cell from freezing cockpit. + this.surfaceAcquiring = true; + this.surfaceFallback = false; + if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { + this.lastHudUpdateMs = nowMs; + this.updateHud(info, nowMs); + } + return; + } + this.surfaceAcquiring = false; + this.surfaceFallback = true; + } else { + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + } + } + } else { + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + } + if (!Number.isFinite(cockpitFloorM) + && speedMps < 90 + && Number.isFinite(targetCartographic?.height)) { + cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M; + } + if (anchorCartographic && Number.isFinite(cockpitFloorM)) { + const minimumAnchorHeightM = cockpitGroundSafeHeight( + anchorCartographic.height, + cockpitFloorM, + COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, + ); + if (minimumAnchorHeightM !== anchorCartographic.height) { + anchorCartographic.height = minimumAnchorHeightM; + Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor); + } + } + + // Rebuild the local frame at the stabilized anchor after advancing it. + Cesium.Transforms.eastNorthUpToFixedFrame( + this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, + ); + this.scratchLocal.x = Math.sin(headingRad); + this.scratchLocal.y = Math.cos(headingRad); + this.scratchLocal.z = 0; + Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); + Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); + + this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); + this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); + this.scratchLocal.z = Math.sin(pitchRad); + Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward); + Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); + + this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); + this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); + this.scratchLocal.z = Math.cos(pitchRad); + Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp); + Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp); + + Cesium.Cartesian3.multiplyByScalar( + this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera, + ); + Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera); + Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget); + Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); + Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget); + Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera); + + // Recheck at the final forward-offset camera coordinate because a taxiing + // aircraft can cross into an adjacent coarse floor cell between updates. + const cameraCartographic = Cesium.Cartographic.fromCartesian( + this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, + ); + if (cameraCartographic) { + const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); + const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); + const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon); + if (Number.isFinite(cameraFloorM)) { + cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM); + } + const safeHeightM = cockpitGroundSafeHeight( + cameraCartographic.height, + cockpitFloorM, + COCKPIT_MIN_GROUND_CLEARANCE_M, + ); + if (safeHeightM !== cameraCartographic.height) { + cameraCartographic.height = safeHeightM; + Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera); + } + } + + this.viewer.camera.setView({ + destination: this.scratchCamera, + orientation: { direction: this.scratchForward, up: this.scratchUp }, + }); + if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { + this.lastHudUpdateMs = nowMs; + this.updateHud(info, nowMs); + } + } diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js new file mode 100644 index 000000000..b62445598 --- /dev/null +++ b/src/ui/cockpitContext.js @@ -0,0 +1,134 @@ +/** Contact readouts and nearby-signal presentation for the Cockpit controller. */ +import * as Cesium from 'cesium'; +import { bearingBetweenCoordinates, formatCockpitContextScope, relativeBearing, resolveCockpitContextReadout } from '../cockpitMath.js'; + +export function updateContext(info, heading) { + if (!this.context) return; + const snapshot = this.services.militaryAwarenessLayer.getContextSnapshot?.() || null; + const trackedId = info.icao24 || info.id; + const readout = resolveCockpitContextReadout({ snapshot, info }); + if (!readout.visible) { + this.context.hidden = true; + this.hud?.classList.remove('context-active'); + this.contextLayoutStamp = null; + this.pushCockpitSignal( + 'context-status', + 'info', + 'CONTEXT STANDBY', + 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', + ); + return; + } + + this.context.hidden = false; + this.hud?.classList.add('context-active'); + if (this.contextSubject) { + const installationCoverage = snapshot.cohorts + .find((cohort) => cohort.id === 'military-installations')?.coverage; + this.contextSubject.textContent = formatCockpitContextScope( + snapshot.subject.label || trackedId, + snapshot.radiusM, + installationCoverage, + ); + } + // Navigation stays wired in every state — the operator must always be able + // to step off the current contact from the panel that hosts the controls. + if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; + if (this.contextNext) this.contextNext.disabled = !snapshot.navigation?.canNext; + + if (readout.contactLost) { + // The subject left its source. Every number below is measured against a + // position that stopped updating, so hold the last rendered readout and + // say so instead of re-deriving stale geometry as if it were live. + const enteringLost = this.context.dataset.state !== 'lost'; + this.context.dataset.state = 'lost'; + if (this.contextUncertainty) { + this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; + } + // The cue changes the footer's height; re-run layout once on the way in + // rather than every frame the contact stays lost. + if (enteringLost) this.scheduleContextLayout(); + this.pushCockpitSignal( + 'context-status', + 'warning', + `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`, + 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', + ); + return; + } + + let unknownCount = 0; + const nearest = []; + for (const cohort of snapshot.cohorts) { + const element = this.contextCohorts.get(cohort.id); + const value = element?.querySelector('strong'); + if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count); + element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); + if (cohort.count === null) unknownCount += 1; + for (const item of cohort.nearest) nearest.push({ ...item, cohort }); + } + nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity)); + const closest = nearest[0] || null; + const closestLabel = this.services.formatAwarenessLabel(closest); + if (this.contextNearestLabel) { + this.contextNearestLabel.textContent = closest + ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE'; + this.contextNearestLabel.setAttribute( + 'aria-label', + closest && closestLabel === '—' + ? `${closest.cohort.label}, Unavailable` + : this.contextNearestLabel.textContent, + ); + } + if (this.contextDistance) { + const distanceM = closest?.distanceM; + this.contextDistance.textContent = Number.isFinite(distanceM) + ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—'; + this.contextDistance.setAttribute( + 'aria-label', + Number.isFinite(distanceM) ? this.contextDistance.textContent : 'Unavailable', + ); + } + + // The arrow and BRG are nose-relative to the tracked aircraft. When the + // subject is a vessel, an installation, or another aircraft, the rest of + // this row is measured from that subject — so the aircraft-frame half is + // dashed rather than presented alongside subject-frame distances as if the + // two shared an origin. + let relative = null; + if (readout.aircraftRelative + && closest?.position && Number.isFinite(info.latitude) && Number.isFinite(info.longitude)) { + const cartographic = Cesium.Cartographic.fromCartesian(closest.position); + const bearing = cartographic ? bearingBetweenCoordinates( + info.latitude, + info.longitude, + Cesium.Math.toDegrees(cartographic.latitude), + Cesium.Math.toDegrees(cartographic.longitude), + ) : null; + relative = relativeBearing(bearing, heading); + } + if (this.contextDirection) { + this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`; + this.contextDirection.classList.toggle('unknown', relative === null); + } + if (this.contextBearing) { + if (relative === null) this.contextBearing.textContent = 'BRG —'; + else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; + else this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`; + } + if (this.contextUncertainty) { + this.contextUncertainty.textContent = unknownCount + ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR` + : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'; + } + if (this.contextUpdated) { + this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) + ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; + } + this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; + this.updateCockpitSignals(snapshot, unknownCount); + if (this.contextLayoutStamp !== snapshot.evaluatedAt) { + this.contextLayoutStamp = snapshot.evaluatedAt; + this.scheduleContextLayout(); + } + } diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js new file mode 100644 index 000000000..48d09fb79 --- /dev/null +++ b/src/ui/cockpitController.js @@ -0,0 +1,259 @@ +/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */ +import { readAircraftInfo, dispatchCockpitModeChanged, toggleTrackedTr3b, syncTr3bToggle, syncEntry, navigateContext, _adoptTrackedEntity, enter, exit } from './cockpitTrackingController.js'; +import { update } from './cockpitCamera.js'; +import { updateHud, updateRoute, syncWeatherToggle, setVisionMode, cycleVisionMode, clearPredictiveRoute } from './cockpitInstruments.js'; +import { updateContext } from './cockpitContext.js'; +import { showBriefPage, setBriefAutoRotate, startBriefRotation, stopBriefRotation, updateLocalPosition, maybeRefreshRegionalBrief, renderRegionalBriefStatus, renderRegionalBrief } from './cockpitBriefing.js'; +import { handleSignalClick, renderCockpitSignals, pushCockpitSignal, updateCockpitSignals } from './cockpitSignals.js'; +import { scheduleContextLayout, setContextCollapsed, setSignalCollapsed, syncContextLayout, syncSignalLayout } from './cockpitLayout.js'; +import { onKeyDown } from './cockpitInput.js'; +import * as Cesium from 'cesium'; + +export class CockpitViewController { + constructor(viewer, { + services, + onVisionChange = null, + onCameraTakeover = null, + isEntryAllowed = null, + onEntered = null, + onExited = null, + getInheritedVisionLabel = null, + restoreTrackingFrame = null, + } = {}) { + this.viewer = viewer; + this.services = services; + this.destroyed = false; + this.active = false; + this.trackedEntity = null; + this.trackedEntityWasShown = true; + this.heading = null; + this.lastFrameMs = 0; + this.lastCameraUpdateMs = 0; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.lastGroundProbeMs = 0; + this.contextNavigationDeadlineMs = 0; + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + this.lastCompassSignature = ''; + this.entry = document.getElementById('cockpit-entry'); + this.tr3bToggle = document.getElementById('tr3b-toggle'); + this._tr3bSignature = null; + this.mapViewButton = document.getElementById('map-view-switch'); + this.resetGlobeButton = document.getElementById('cockpit-reset-globe'); + this.hud = document.getElementById('cockpit-hud'); + this.entryFocusOrigin = null; + this.callsign = document.getElementById('cockpit-callsign'); + this.speed = document.getElementById('cockpit-speed-value'); + this.speedRim = document.getElementById('cockpit-speed-rim'); + this.speedRimValue = document.getElementById('cockpit-speed-rim-value'); + this.speedRimTicks = Array.from(document.querySelectorAll('[data-speed-rim-tick]')); + this.altitude = document.getElementById('cockpit-altitude-value'); + this.altitudeRim = document.getElementById('cockpit-altitude-rim'); + this.altitudeRimValue = document.getElementById('cockpit-altitude-rim-value'); + this.altitudeRimTicks = Array.from(document.querySelectorAll('[data-altitude-rim-tick]')); + this.headingValue = document.getElementById('cockpit-heading-value'); + this.compassTape = document.getElementById('cockpit-compass-tape'); + this.clock = document.getElementById('cockpit-clock'); + this.position = document.getElementById('cockpit-position'); + this.aircraftMeta = document.getElementById('cockpit-aircraft-meta'); + this.route = document.getElementById('cockpit-route'); + this.routeFrom = document.getElementById('cockpit-route-from'); + this.routeTo = document.getElementById('cockpit-route-to'); + this.routeStatus = document.getElementById('cockpit-route-status'); + this.routeDirection = document.getElementById('cockpit-route-direction'); + this.routeDirectionLabel = document.getElementById('cockpit-route-direction-label'); + this.visionPrevious = document.getElementById('cockpit-vision-previous'); + this.visionCurrent = document.getElementById('cockpit-vision-current'); + this.visionCurrentLabel = document.getElementById('cockpit-vision-current-label'); + this.visionNext = document.getElementById('cockpit-vision-next'); + this.visionMode = 'optical'; + this.onVisionChange = onVisionChange; + this.onCameraTakeover = onCameraTakeover; + this.onEntered = onEntered; + this.onExited = onExited; + this.getInheritedVisionLabel = typeof getInheritedVisionLabel === 'function' + ? getInheritedVisionLabel + : () => 'NORMAL'; + this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function' + ? restoreTrackingFrame + : () => false; + this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; + this.context = document.getElementById('cockpit-context'); + this.contextSubject = document.getElementById('cockpit-context-subject'); + this.contextNearestLabel = document.getElementById('cockpit-context-nearest-label'); + this.contextBearing = document.getElementById('cockpit-context-bearing'); + this.contextDistance = document.getElementById('cockpit-context-distance'); + this.contextDirection = document.getElementById('cockpit-context-direction'); + this.contextUncertainty = document.getElementById('cockpit-context-uncertainty'); + this.contextUpdated = document.getElementById('cockpit-context-updated'); + this.contextCohorts = new Map(Array.from(document.querySelectorAll('[data-context-cohort]')) + .map((element) => [element.dataset.contextCohort, element])); + this.contextPrevious = document.getElementById('cockpit-context-previous'); + this.contextNext = document.getElementById('cockpit-context-next'); + this.contextToggle = document.getElementById('cockpit-context-toggle'); + this.weatherToggle = document.getElementById('cockpit-weather-toggle'); + this.weatherState = document.getElementById('cockpit-weather-state'); + this.contextCollapsed = false; + this.signalStream = document.getElementById('cockpit-signal-stream'); + this.signalList = document.getElementById('cockpit-signal-list'); + this.signalToggle = document.getElementById('cockpit-signal-toggle'); + this.briefKicker = document.getElementById('cockpit-brief-kicker'); + this.briefSubtitle = document.getElementById('cockpit-brief-subtitle'); + this.briefPrevious = document.getElementById('cockpit-brief-previous'); + this.briefNext = document.getElementById('cockpit-brief-next'); + this.briefAutoToggle = document.getElementById('cockpit-brief-auto'); + this.briefPosition = document.getElementById('cockpit-brief-position'); + this.briefSource = document.getElementById('cockpit-brief-source'); + this.briefPages = Array.from(document.querySelectorAll('[data-cockpit-brief-page]')); + this.briefTabs = Array.from(document.querySelectorAll('[data-cockpit-brief-index]')); + this.newsStatus = document.getElementById('cockpit-news-status'); + this.newsList = document.getElementById('cockpit-news-list'); + this.localPlace = document.getElementById('cockpit-local-place'); + this.localCoordinates = document.getElementById('cockpit-local-coordinates'); + this.localTemperature = document.getElementById('cockpit-local-temperature'); + this.localWind = document.getElementById('cockpit-local-wind'); + this.localWindDirection = document.getElementById('cockpit-local-wind-direction'); + this.localCondition = document.getElementById('cockpit-local-condition'); + this.localCloud = document.getElementById('cockpit-local-cloud'); + this.localPrecipitation = document.getElementById('cockpit-local-precipitation'); + this.signalCollapsed = false; + this.signalUserCollapsed = false; + this.signalItems = []; + this.signalSignatures = new Map(); + this.briefPageIndex = 0; + this.briefAutoRotateEnabled = false; + this.briefTimer = null; + this.lastAircraftInfo = null; + this.regionalBrief = null; + this.regionalBriefAnchor = null; + this.regionalBriefFetchedAt = 0; + this.regionalBriefAbort = null; + this.regionalBriefRequestToken = 0; + this.regionalBriefSubjectId = null; + this.contextLayoutFrame = null; + this.contextLayoutStamp = null; + this.scratchTarget = new Cesium.Cartesian3(); + this.cockpitAnchor = new Cesium.Cartesian3(); + this.cockpitAnchorValid = false; + this.scratchCamera = new Cesium.Cartesian3(); + this.scratchAdvance = new Cesium.Cartesian3(); + this.scratchCorrection = new Cesium.Cartesian3(); + this.scratchForward = new Cesium.Cartesian3(); + this.scratchHorizontal = new Cesium.Cartesian3(); + this.scratchUp = new Cesium.Cartesian3(); + this.scratchLocal = new Cesium.Cartesian3(); + this.scratchEnu = new Cesium.Matrix4(); + this.scratchAnchorCartographic = new Cesium.Cartographic(); + this.scratchCameraCartographic = new Cesium.Cartographic(); + this.scratchTargetCartographic = new Cesium.Cartographic(); + this._listenerRemovers = []; + + // Camera mutations belong before scene update/culling. Changing the camera + // from preRender makes 3D Tiles discover a new view after traversal and can + // create a self-sustaining refinement loop under a moving cockpit camera. + this._listenerRemovers.push( + viewer.scene.preUpdate.addEventListener(() => this.update()), + viewer.trackedEntityChanged.addEventListener(() => { + if (this.active) this._adoptTrackedEntity(performance.now()); + else this.syncEntry(); + }), + ); + this._listen(this.entry, 'click', () => this.enter()); + this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b()); + this._listen(this.mapViewButton, 'click', () => this.exit()); + this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); + this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1)); + this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1)); + this._listen(this.contextPrevious, 'click', () => this.navigateContext(-1, { origin: 'user' })); + this._listen(this.contextNext, 'click', () => this.navigateContext(1, { origin: 'user' })); + this._listen(this.contextToggle, 'click', () => this.setContextCollapsed(!this.contextCollapsed)); + this._listen(this.weatherToggle, 'click', () => { + const enabled = this.weatherToggle.getAttribute('aria-pressed') !== 'true'; + this.syncWeatherToggle(enabled); + window.dispatchEvent(new CustomEvent('gev:cockpit-weather-toggle', { + detail: { enabled }, + })); + }); + this._listen(window, 'gev:cockpit-weather-state', (event) => { + this.syncWeatherToggle(event?.detail?.enabled !== false); + }); + this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed( + !this.signalCollapsed, + { user: true }, + )); + this._listen(this.signalList, 'click', (event) => this.handleSignalClick(event)); + this._listen(this.briefPrevious, 'click', () => this.showBriefPage(this.briefPageIndex - 1, { manual: true })); + this._listen(this.briefNext, 'click', () => this.showBriefPage(this.briefPageIndex + 1, { manual: true })); + this._listen(this.briefAutoToggle, 'click', () => { + this.setBriefAutoRotate(!this.briefAutoRotateEnabled); + }); + this.briefTabs.forEach((button) => this._listen(button, 'click', () => { + this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { manual: true }); + })); + this._listen(document, 'visibilitychange', () => { + if (document.hidden) this.stopBriefRotation(); + else if (this.briefAutoRotateEnabled) this.startBriefRotation(); + }); + this._listen(window, 'resize', () => this.scheduleContextLayout()); + this._listen(document, 'keydown', (event) => this.onKeyDown(event), true); + } + + _listen(target, type, handler, options) { + if (!target?.addEventListener) return; + target.addEventListener(type, handler, options); + this._listenerRemovers.push(() => target.removeEventListener(type, handler, options)); + } + + readAircraftInfo() { return readAircraftInfo.call(this); } + dispatchCockpitModeChanged(active, info) { return dispatchCockpitModeChanged.call(this, active, info); } + toggleTrackedTr3b() { return toggleTrackedTr3b.call(this); } + syncTr3bToggle(info) { return syncTr3bToggle.call(this, info); } + syncEntry() { return syncEntry.call(this); } + navigateContext(direction, options) { return navigateContext.call(this, direction, options); } + _adoptTrackedEntity(nowMs, suppliedInfo) { return _adoptTrackedEntity.call(this, nowMs, suppliedInfo); } + enter() { return enter.call(this); } + exit(options) { return exit.call(this, options); } + update() { return update.call(this); } + updateHud(info, nowMs, forceContext) { return updateHud.call(this, info, nowMs, forceContext); } + updateRoute(info) { return updateRoute.call(this, info); } + syncWeatherToggle(enabled) { return syncWeatherToggle.call(this, enabled); } + setVisionMode(mode, options) { return setVisionMode.call(this, mode, options); } + cycleVisionMode(direction) { return cycleVisionMode.call(this, direction); } + clearPredictiveRoute() { return clearPredictiveRoute.call(this); } + updateContext(info, heading) { return updateContext.call(this, info, heading); } + showBriefPage(index, options) { return showBriefPage.call(this, index, options); } + setBriefAutoRotate(enabled) { return setBriefAutoRotate.call(this, enabled); } + startBriefRotation(options) { return startBriefRotation.call(this, options); } + stopBriefRotation() { return stopBriefRotation.call(this); } + updateLocalPosition(info) { return updateLocalPosition.call(this, info); } + maybeRefreshRegionalBrief(info) { return maybeRefreshRegionalBrief.call(this, info); } + renderRegionalBriefStatus(status, info) { return renderRegionalBriefStatus.call(this, status, info); } + renderRegionalBrief(payload, info) { return renderRegionalBrief.call(this, payload, info); } + renderCockpitSignals() { return renderCockpitSignals.call(this); } + pushCockpitSignal(key, tone, title, detail, target) { return pushCockpitSignal.call(this, key, tone, title, detail, target); } + updateCockpitSignals(snapshot, unknownCount) { return updateCockpitSignals.call(this, snapshot, unknownCount); } + scheduleContextLayout() { return scheduleContextLayout.call(this); } + setContextCollapsed(collapsed) { return setContextCollapsed.call(this, collapsed); } + setSignalCollapsed(collapsed, options) { return setSignalCollapsed.call(this, collapsed, options); } + syncContextLayout() { return syncContextLayout.call(this); } + syncSignalLayout() { return syncSignalLayout.call(this); } + onKeyDown(event) { return onKeyDown.call(this, event); } + + handleSignalClick(event) { return handleSignalClick.call(this, event); } + + dispose() { + if (this.destroyed) return; + this.destroyed = true; + this.exit({ restoreTracking: false }); + this.regionalBriefAbort?.abort(); + this.regionalBriefAbort = null; + this.regionalBriefRequestToken += 1; + this.stopBriefRotation(); + if (this.contextLayoutFrame !== null) cancelAnimationFrame(this.contextLayoutFrame); + this.contextLayoutFrame = null; + for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.(); + } +} + diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs new file mode 100644 index 000000000..31fdb5a4f --- /dev/null +++ b/src/ui/cockpitController.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CockpitViewController } from './cockpitController.js'; + +function environment() { + const original = { document: globalThis.document, window: globalThis.window }; + const document = Object.assign(new EventTarget(), { + getElementById: () => null, + querySelectorAll: () => [], + body: { classList: { remove() {} } }, + }); + const window = Object.assign(new EventTarget(), { clearTimeout, setTimeout }); + globalThis.document = document; + globalThis.window = window; + const callbacks = new Set(); + const event = { addEventListener(fn) { callbacks.add(fn); return () => callbacks.delete(fn); } }; + const viewer = { + scene: { preUpdate: event, screenSpaceCameraController: { enableInputs: false } }, + trackedEntityChanged: event, + entities: { contains: () => false }, + }; + return { viewer, callbacks, restore() { Object.assign(globalThis, original); } }; +} + +test('Cockpit subscriptions are released once and retained actions cannot restart it', () => { + const env = environment(); + try { + const owner = new CockpitViewController(env.viewer, { services: {} }); + assert.equal(env.callbacks.size, 2); + owner.dispose(); + owner.dispose(); + assert.equal(env.callbacks.size, 0); + assert.equal(owner.enter(), false); + assert.equal(owner.navigateContext(1), false); + assert.equal(owner.toggleTrackedTr3b(), false); + assert.equal(owner.update(), false); + assert.equal(owner.active, false); + } finally { env.restore(); } +}); + +test('disposing an active Cockpit releases the supplied render owner without retracking', () => { + const env = environment(); + try { + const released = []; + const owner = new CockpitViewController(env.viewer, { + services: { releaseContinuousRender: reason => released.push(reason) }, + }); + owner.active = true; + owner.dispose(); + owner.dispose(); + assert.deepEqual(released, ['cockpit']); + assert.equal(env.viewer.scene.screenSpaceCameraController.enableInputs, true); + assert.equal(env.viewer.trackedEntity, undefined); + assert.equal(owner.active, false); + } finally { env.restore(); } +}); + +test('aircraft identity is read from the supplied layer instances', () => { + const env = environment(); + try { + const civilian = { icao24: 'abc123', layerId: 'flights', callsign: 'CIVIL' }; + const military = { icao24: 'def456', layerId: 'military', callsign: 'MILITARY' }; + const owner = new CockpitViewController(env.viewer, { services: { + flightsLayer: { getTrackedInfo: () => civilian }, + militaryFlightsLayer: { getTrackedInfo: () => military }, + } }); + env.viewer.trackedEntity = { gevTrackedId: 'military:def456' }; + assert.deepEqual(owner.readAircraftInfo(), military); + env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' }; + assert.deepEqual(owner.readAircraftInfo(), civilian); + owner.dispose(); + } finally { env.restore(); } +}); + +test('an obsolete regional briefing cannot publish after Cockpit disposal', async () => { + const env = environment(); + try { + let finish; + let signal; + const owner = new CockpitViewController(env.viewer, { services: { + regionalDistanceM: () => Infinity, + fetchRegionalBrief: (_lat, _lon, options) => { + signal = options.signal; + return new Promise(resolve => { finish = resolve; }); + }, + releaseContinuousRender() {}, + } }); + const published = []; + owner.renderRegionalBrief = payload => published.push(payload); + owner.active = true; + owner.maybeRefreshRegionalBrief({ layerId: 'flights', icao24: 'abc123', latitude: 30, longitude: -97 }); + owner.dispose(); + assert.equal(signal.aborted, true); + finish({ articles: [] }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(published, []); + assert.equal(owner.regionalBriefAbort, null); + } finally { env.restore(); } +}); diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js new file mode 100644 index 000000000..4da1dc13f --- /dev/null +++ b/src/ui/cockpitDisplayPortal.js @@ -0,0 +1,90 @@ +/** Move the existing Display groups between their two presentation surfaces. */ +export class CockpitDisplayPortal { + constructor({ standardPanel, cockpitPanel, groups, layout }) { + this.standardPanel = standardPanel; + this.cockpitPanel = cockpitPanel; + this.layout = layout; + this.active = false; + this.destroyed = false; + this.restoreOwner = null; + this.generation = 0; + this.frames = new Set(); + this.listeners = new AbortController(); + this.records = groups.flatMap(([name, group]) => { + const slot = cockpitPanel?.querySelector(`[data-cockpit-display-slot="${name}"]`); + if (!group || !slot || !group.parentNode) return []; + const anchor = document.createComment(`cockpit-display-home:${name}`); + group.before(anchor); + return [{ name, group, slot, anchor }]; + }); + this.standardScrollTop = standardPanel?.scrollTop || 0; + this.cockpitScrollTop = cockpitPanel?.scrollTop || 0; + const options = { passive: true, signal: this.listeners.signal }; + standardPanel?.addEventListener('scroll', () => { + if (!this.active) this.standardScrollTop = standardPanel.scrollTop; + }, options); + cockpitPanel?.addEventListener('scroll', () => { + if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop; + }, options); + window.addEventListener('gev:cockpit-mode-changed', event => { + this.setActive(event?.detail?.active === true); + }, { signal: this.listeners.signal }); + this.setActive(document.body.classList.contains('cockpit-mode')); + } + + _frame(generation, callback) { + const frame = requestAnimationFrame(() => { + this.frames.delete(frame); + if (!this.destroyed && generation === this.generation) callback(); + }); + this.frames.add(frame); + } + + _cancelFrames() { + for (const frame of this.frames) cancelAnimationFrame(frame); + this.frames.clear(); + this.generation += 1; + } + + setActive(active, { settle = true } = {}) { + if (this.destroyed) return; + const nextActive = active === true; + if (this.active === nextActive) return; + this._cancelFrames(); + const generation = this.generation; + const focusedRecord = this.records.find(record => record.group.contains(document.activeElement)); + const focusedElement = focusedRecord ? document.activeElement : null; + this.restoreOwner = nextActive ? 'cockpit' : 'standard'; + this.active = nextActive; + for (const record of this.records) { + if (nextActive) record.slot.append(record.group); + else if (record.anchor.parentNode) record.anchor.after(record.group); + } + this.cockpitPanel?.classList.toggle('uses-shared-display-controls', nextActive); + const restoreScroll = () => { + if (nextActive && this.cockpitPanel) this.cockpitPanel.scrollTop = this.cockpitScrollTop; + if (!nextActive && this.standardPanel) this.standardPanel.scrollTop = this.standardScrollTop; + }; + if (!settle) { restoreScroll(); this.restoreOwner = null; return; } + this._frame(generation, () => { + restoreScroll(); + focusedElement?.focus?.({ preventScroll: true }); + this._frame(generation, () => { + restoreScroll(); + this.restoreOwner = null; + }); + }); + this.layout(); + } + + destroy() { + if (this.destroyed) return; + this.setActive(false, { settle: false }); + this.destroyed = true; + this._cancelFrames(); + this.listeners.abort(); + for (const record of this.records) record.anchor.remove(); + this.records = []; + this.restoreOwner = null; + } +} diff --git a/src/ui/cockpitDisplayPortal.test.mjs b/src/ui/cockpitDisplayPortal.test.mjs new file mode 100644 index 000000000..aed7d0c74 --- /dev/null +++ b/src/ui/cockpitDisplayPortal.test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CockpitDisplayPortal } from './cockpitDisplayPortal.js'; + +function fixture() { + const prior = Object.fromEntries(['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map(key => [key, globalThis[key]])); + const frames = new Map(); + let nextFrame = 0; + let focusCalls = 0; + let layouts = 0; + class Node extends EventTarget { + constructor(name) { + super(); this.name = name; this.children = []; this.parentNode = null; this.scrollTop = 0; + this.classList = { toggle() {} }; + } + append(node) { + node.remove(); this.children.push(node); node.parentNode = this; + } + remove() { + if (this.parentNode) this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + this.parentNode = null; + } + before(node) { + node.remove(); const parent = this.parentNode; + parent.children.splice(parent.children.indexOf(this), 0, node); node.parentNode = parent; + } + after(node) { + node.remove(); const parent = this.parentNode; + parent.children.splice(parent.children.indexOf(this) + 1, 0, node); node.parentNode = parent; + } + contains(node) { return this === node || this.children.some(child => child.contains(node)); } + focus() { focusCalls += 1; document.activeElement = this; } + } + const standard = new Node('standard'); standard.scrollTop = 71; + const cockpit = new Node('cockpit'); cockpit.scrollTop = 19; + const group = new Node('group'); const sibling = new Node('sibling'); + const slot = new Node('slot'); + standard.append(group); standard.append(sibling); cockpit.append(slot); + cockpit.querySelector = () => slot; + globalThis.document = { createComment: name => new Node(name), activeElement: group, body: { classList: { contains: () => false } } }; + globalThis.window = new EventTarget(); + globalThis.requestAnimationFrame = fn => { const id = ++nextFrame; frames.set(id, fn); return id; }; + globalThis.cancelAnimationFrame = id => frames.delete(id); + const create = () => new CockpitDisplayPortal({ standardPanel: standard, cockpitPanel: cockpit, groups: [['hud', group]], layout: () => { layouts += 1; } }); + return { standard, cockpit, group, sibling, slot, frames, create, + get focusCalls() { return focusCalls; }, get layouts() { return layouts; }, + flush() { while (frames.size) { const batch = [...frames.values()]; frames.clear(); batch.forEach(fn => fn()); } }, + restore() { Object.assign(globalThis, prior); }, + }; +} + +test('Display groups return to their exact home with independent scroll positions', () => { + const f = fixture(); + try { + const owner = f.create(); + owner.setActive(true); + assert.equal(f.group.parentNode, f.slot); + f.cockpit.scrollTop = 0; + f.flush(); + assert.equal(f.cockpit.scrollTop, 19); + assert.equal(f.focusCalls, 1); + owner.setActive(false); + f.standard.scrollTop = 0; + f.flush(); + assert.equal(f.standard.scrollTop, 71); + owner.destroy(); + assert.deepEqual(f.standard.children, [f.group, f.sibling]); + assert.equal(f.frames.size, 0); + } finally { f.restore(); } +}); + +test('superseded portal frames cannot restore obsolete focus or scroll', () => { + const f = fixture(); + try { + const owner = f.create(); + owner.setActive(true); + const obsolete = [...f.frames.values()]; + owner.setActive(false); + f.cockpit.scrollTop = 93; + obsolete.forEach(callback => callback()); + assert.equal(f.focusCalls, 0); + assert.equal(f.cockpit.scrollTop, 93); + f.flush(); + assert.equal(f.focusCalls, 1); + assert.equal(owner.restoreOwner, null); + owner.destroy(); + } finally { f.restore(); } +}); + +test('disposal restores groups and revokes mode listeners and queued focus work', () => { + const f = fixture(); + try { + const owner = f.create(); + window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } })); + assert.equal(f.group.parentNode, f.slot); + const obsolete = [...f.frames.values()]; + owner.destroy(); owner.destroy(); + obsolete.forEach(callback => callback()); + window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } })); + owner.setActive(true); + assert.deepEqual(f.standard.children, [f.group, f.sibling]); + assert.equal(f.frames.size, 0); + assert.equal(f.focusCalls, 0); + assert.equal(f.layouts, 1); + } finally { f.restore(); } +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js new file mode 100644 index 000000000..21c55a2b8 --- /dev/null +++ b/src/ui/cockpitInput.js @@ -0,0 +1,54 @@ +/** Route Cockpit keys while preserving the active disclosure and form-control owner. */ + + +export function onKeyDown(event) { + if (this.destroyed) return false; + if (event.repeat || event.isComposing) return; + if (event.key === 'Escape' && this.active) { + // The credit lightbox owns Escape while its Close control or links hold + // focus. Its target handler closes the overlay and restores attribution + // focus; Cockpit must stay active behind it. + if (event.target?.closest?.('.cesium-credit-lightbox')) return; + if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return; + if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return; + if (this.context?.contains(event.target) && !this.contextCollapsed) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.setContextCollapsed(true); + if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) { + this.contextToggle?.blur?.(); + } else { + this.contextToggle?.focus({ preventScroll: true }); + } + return; + } + if (this.signalStream?.contains(event.target) && !this.signalCollapsed) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.setSignalCollapsed(true, { user: true }); + if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) { + this.signalToggle?.blur?.(); + } else { + this.signalToggle?.focus({ preventScroll: true }); + } + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + this.exit(); + return; + } + if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; + const key = event.key?.toLowerCase(); + if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { + if (!this.active) { + const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position); + if (!cockpitAttempt) return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + if (!this.active && !this.isEntryAllowed()) return; + const changed = this.active ? this.exit() : this.enter(); + return; + } + } diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js new file mode 100644 index 000000000..07ac2e637 --- /dev/null +++ b/src/ui/cockpitInstruments.js @@ -0,0 +1,182 @@ +/** Instrument values, routes and vision controls for the Cockpit controller. */ +import { COCKPIT_VISION_MODES, normalizeCockpitVisionMode } from '../cockpitVisionPolicy.js'; +import { altitudeRulerCurveInset, altitudeRulerTicks, bearingBetweenCoordinates, cockpitAltitudeDisplayFt, cockpitUiUpdateDue, compassDivisions, formatAltitudeRulerTick, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, relativeBearing, speedRulerTicks } from '../cockpitMath.js'; +import { COCKPIT_CONTEXT_UPDATE_MS, setCockpitRollingValue } from './cockpitPresentation.js'; + +export function updateHud(info, nowMs = performance.now(), forceContext = false) { + this.lastAircraftInfo = info; + const heading = normalizeHeading(this.heading ?? info.track ?? 0); + if (this.callsign) { + this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; + } + const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null; + setCockpitRollingValue( + this.speed, + formatSpeedRulerTick(speedKt), + speedKt, + { immediate: forceContext }, + ); + if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null); + if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); + const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); + this.speedRimTicks.forEach((element, index) => { + const tick = speedTicks[index]; + element.hidden = !tick; + if (!tick) return; + element.style.setProperty('--slot', tick.slot.toFixed(4)); + element.style.setProperty('--depth', tick.depth.toFixed(4)); + element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); + element.classList.toggle('major', tick.major); + const label = element.querySelector('b'); + if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); + }); + const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround); + if (this.altitude) { + const displayedAltitudeFt = Number.isFinite(altitudeFt) + ? Math.round(altitudeFt) + : null; + setCockpitRollingValue( + this.altitude, + displayedAltitudeFt !== null + ? displayedAltitudeFt.toLocaleString('en-US') + : '-----', + displayedAltitudeFt, + { immediate: forceContext }, + ); + } + if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); + if (this.altitudeRimValue) { + this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); + } + const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length); + this.altitudeRimTicks.forEach((element, index) => { + const tick = altitudeTicks[index]; + element.hidden = !tick; + if (!tick) return; + element.style.setProperty('--slot', tick.slot.toFixed(4)); + element.style.setProperty('--depth', tick.depth.toFixed(4)); + element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); + element.classList.toggle('major', tick.major); + const label = element.querySelector('b'); + if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); + }); + setCockpitRollingValue( + this.headingValue, + String(Math.round(heading) % 360).padStart(3, '0'), + heading, + { circularRange: 360, immediate: forceContext }, + ); + if (this.compassTape) { + const divisions = compassDivisions(heading); + const signature = divisions.join(','); + if (signature !== this.lastCompassSignature) { + this.lastCompassSignature = signature; + this.compassTape.innerHTML = divisions + .map((division, index) => { + const slot = index - 3; + return `${formatCompassDivision(division)}`; + }) + .join(''); + } + } + if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; + if (this.position) { + const lat = Number.isFinite(info.latitude) + ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--'; + const lon = Number.isFinite(info.longitude) + ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--'; + this.position.textContent = `${lat} · ${lon}`; + } + if (this.aircraftMeta) { + const feedState = this.surfaceAcquiring + ? 'ACQUIRING SURFACE' + : (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK')); + this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; + } + this.updateRoute(info); + if (forceContext + || cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) { + this.lastContextUpdateMs = nowMs; + this.updateLocalPosition(info); + this.maybeRefreshRegionalBrief(info); + this.updateContext(info, heading); + } + if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; + } + +export function updateRoute(info) { + const origin = info?.route?.origin; + const destination = info?.route?.destination; + const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); + const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN'; + if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); + if (this.routeTo) this.routeTo.textContent = routeLabel(destination); + if (this.routeStatus) { + this.routeStatus.textContent = validDestination + ? 'ARROW · ESTIMATED DIRECTION' + : 'ROUTE DATA UNAVAILABLE'; + } + if (this.route) this.route.hidden = !origin && !destination; + if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) { + this.clearPredictiveRoute(); + return; + } + const destinationBearing = bearingBetweenCoordinates( + info.latitude, + info.longitude, + destination.lat, + destination.lon, + ); + const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0); + if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) { + this.clearPredictiveRoute(); + return; + } + if (this.routeDirection) { + const displayedRelative = Math.max(-120, Math.min(120, relative)); + this.routeDirection.hidden = false; + this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`); + } + if (this.routeDirectionLabel) { + this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`; + } + } + +export function syncWeatherToggle(enabled) { + if (!this.weatherToggle) return; + const active = !!enabled; + this.weatherToggle.setAttribute('aria-pressed', String(active)); + this.weatherToggle.setAttribute( + 'aria-label', + `${active ? 'Disable' : 'Enable'} cockpit weather effects`, + ); + this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`; + if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF'; + } + +export function setVisionMode(mode, { revealParameters = false } = {}) { + const next = normalizeCockpitVisionMode(mode); + this.visionMode = next; + const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase(); + const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' }; + const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' }; + if (this.visionCurrent) { + this.visionCurrent.dataset.cockpitVision = next; + this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`); + this.visionCurrent.title = `Current style: ${names[next]} — click for next`; + } + if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next]; + this.onVisionChange?.(next, this.active, { revealParameters }); + } + +export function cycleVisionMode(direction = 1) { + const modes = COCKPIT_VISION_MODES; + const currentIndex = Math.max(0, modes.indexOf(this.visionMode)); + const step = direction < 0 ? -1 : 1; + const nextIndex = (currentIndex + step + modes.length) % modes.length; + this.setVisionMode(modes[nextIndex], { revealParameters: true }); + } + +export function clearPredictiveRoute() { + if (this.routeDirection) this.routeDirection.hidden = true; + } diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js new file mode 100644 index 000000000..949f60a7f --- /dev/null +++ b/src/ui/cockpitLayout.js @@ -0,0 +1,152 @@ +/** Measure Cockpit panels against visible HUD and viewport obstacles. */ +import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout } from '../cockpitUtilityLayout.js'; +import { COCKPIT_UTILITY_REC_GAP_PX, COCKPIT_UTILITY_SIGNAL_GAP_PX, COCKPIT_UTILITY_MIN_TOP_PX, COCKPIT_UTILITY_MIN_TOP_RATIO, COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, isRenderedOnScreen } from './cockpitPresentation.js'; + +export function scheduleContextLayout() { + if (this.destroyed) return false; + const contextVisible = this.context && !this.context.hidden; + const signalVisible = this.signalStream && !this.signalStream.hidden; + if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return; + this.contextLayoutFrame = requestAnimationFrame(() => { + this.contextLayoutFrame = null; + this.syncContextLayout(); + this.syncSignalLayout(); + }); + } + +export function setContextCollapsed(collapsed) { + const wasCollapsed = this.contextCollapsed; + this.contextCollapsed = Boolean(collapsed); + if (this.context) this.context.dataset.collapsed = String(this.contextCollapsed); + if (this.contextToggle) { + const expanded = !this.contextCollapsed; + this.contextToggle.setAttribute('aria-expanded', String(expanded)); + this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`); + this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`; + const icon = this.contextToggle.querySelector('.material-symbols-outlined'); + if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; + } + if (this.active && wasCollapsed && !this.contextCollapsed) { + window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); + } + this.scheduleContextLayout(); + } + +export function setSignalCollapsed(collapsed, { user = false } = {}) { + const wasCollapsed = this.signalCollapsed; + this.signalCollapsed = Boolean(collapsed); + if (user) this.signalUserCollapsed = this.signalCollapsed; + if (this.signalStream) this.signalStream.dataset.collapsed = String(this.signalCollapsed); + if (this.signalToggle) { + const expanded = !this.signalCollapsed; + this.signalToggle.setAttribute('aria-expanded', String(expanded)); + this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`); + this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`; + const icon = this.signalToggle.querySelector('.material-symbols-outlined'); + if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; + } + if (this.signalCollapsed) this.stopBriefRotation(); + else this.startBriefRotation({ reset: true }); + if (this.active && wasCollapsed && !this.signalCollapsed) { + window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded')); + } + this.scheduleContextLayout(); + } + +export function syncContextLayout() { + if (!this.context || this.context.hidden) return; + if (window.matchMedia('(max-width: 760px)').matches) { + this.context.dataset.layoutMode = 'compact-bottom'; + this.context.style.removeProperty('--cockpit-context-left'); + this.context.style.removeProperty('--cockpit-context-top'); + this.context.style.removeProperty('--cockpit-context-max-height'); + return; + } + + const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); + this.context.dataset.layoutMode = 'bottom-left'; + this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`); + this.context.style.removeProperty('--cockpit-context-top'); + this.context.style.removeProperty('--cockpit-context-max-height'); + } + +export function syncSignalLayout() { + if (!this.signalStream || this.signalStream.hidden) return; + const utilityControls = document.getElementById('cockpit-utility-controls'); + if (window.matchMedia('(max-width: 760px)').matches) { + this.signalStream.dataset.layoutMode = 'compact-top'; + utilityControls?.classList.remove('layout-primary-only'); + utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { + const hiddenSibling = Boolean( + utilityControls.querySelector('.cockpit-utility-control.is-expanded') + && !control.classList.contains('is-expanded') + ); + control.setAttribute('aria-hidden', String(hiddenSibling)); + }); + this.hud?.style.removeProperty('--cockpit-utility-top'); + this.hud?.style.removeProperty('--cockpit-utility-max-height'); + this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height'); + this.signalStream.style.removeProperty('--cockpit-signal-right'); + this.signalStream.style.removeProperty('--cockpit-signal-top'); + this.signalStream.style.removeProperty('--cockpit-signal-max-height'); + return; + } + + const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); + this.signalStream.dataset.layoutMode = 'bottom-right'; + this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`); + this.signalStream.style.removeProperty('--cockpit-signal-top'); + this.signalStream.style.removeProperty('--cockpit-signal-max-height'); + const signalBounds = this.signalStream.getBoundingClientRect(); + const utilityBounds = utilityControls?.getBoundingClientRect(); + if (utilityBounds) { + const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded'); + const collapsedControl = utilityControls?.querySelector( + '.cockpit-utility-control:not(.is-expanded)', + ); + const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher'); + const expandedHeight = expandedControl + ? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height) + : 0; + const collapsedHeight = collapsedLauncher + ? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight) + : 0; + // Cockpit owns this anchor outright. The strip used to inherit the left + // accordion's committed top, which is solved against left-lane obstacles + // and dropped the strip straight through the briefing card below it. + // The readout only anchors the strip while it is genuinely on screen: + // the Minimal variant drops it with `display:none`, but HUD Off hides the + // whole Intel HUD with `visibility`/`opacity`, which keeps its rect. + const recReadout = document.querySelector('#intel-hud .hud-top-right'); + const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null; + const utilityAnchor = resolveCockpitUtilityAnchor({ + recBottom: recBounds ? recBounds.bottom : 0, + signalTop: signalBounds.top, + stripHeight: utilityBounds.height, + viewportHeight: window.innerHeight, + collapsedHeight, + recGap: COCKPIT_UTILITY_REC_GAP_PX, + signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX, + minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX, + minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, + }); + const availableHeight = utilityAnchor.maxHeight; + this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`); + this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`); + const utilityLayout = expandedControl && collapsedControl + ? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight }) + : { primaryOnly: false, expandedMaxHeight: availableHeight }; + utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly); + this.hud?.style.setProperty( + '--cockpit-utility-expanded-max-height', + `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, + ); + utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { + const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl; + control.setAttribute('aria-hidden', String(hiddenSibling)); + if (hiddenSibling && control.contains(document.activeElement)) { + expandedControl?.querySelector('.cockpit-utility-glyph')?.focus({ preventScroll: true }); + } + }); + } + } diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js new file mode 100644 index 000000000..924e7765e --- /dev/null +++ b/src/ui/cockpitPresentation.js @@ -0,0 +1,169 @@ +/** Pure formatting and fixed presentation constants used by Cockpit components. */ +export const COCKPIT_HEADING_SLEW_DPS = 28; + +export const COCKPIT_FORWARD_OFFSET_M = 7; + +export const COCKPIT_UP_OFFSET_M = 2.6; + +export const COCKPIT_MIN_GROUND_CLEARANCE_M = 12; + +export const COCKPIT_VIEW_PITCH_DEG = -4; + +export const COCKPIT_CAMERA_UPDATE_MS = 50; + +export const COCKPIT_HUD_UPDATE_MS = 100; + +export const COCKPIT_CONTEXT_UPDATE_MS = 250; + +export const COCKPIT_UTILITY_REC_GAP_PX = 12; + +export const COCKPIT_UTILITY_SIGNAL_GAP_PX = 8; + +export const COCKPIT_UTILITY_MIN_TOP_PX = 96; + +export const COCKPIT_UTILITY_MIN_TOP_RATIO = 0.12; + +export const COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX = 50; + +export const COCKPIT_GROUND_PROBE_MS = 500; + +export const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000; + +export const COCKPIT_BRIEF_ROTATE_MS = 9000; + +export const COCKPIT_BRIEF_CYCLE_OFF_HELP = 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.'; + +export const COCKPIT_BRIEF_CYCLE_ON_HELP = 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.'; + +export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000; + +export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000; + +export const COCKPIT_BRIEF_PAGES = [ + { + id: 'signals', + kicker: 'LIVE SIGNALS', + subtitle: 'OBSERVED / MAPPED PINGS', + source: 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', + }, + { + id: 'news', + kicker: 'REGIONAL NEWS', + subtitle: 'LATEST LOCATION-MATCHED REPORTING', + source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT', + }, + { + id: 'local', + kicker: 'LOCAL INFO', + subtitle: 'PLACE / CONDITIONS / POSITION', + source: 'OPENSTREETMAP · OPEN-METEO · UTC', + }, +]; + +export function isRenderedOnScreen(element) { + if (!element) return false; + for (let node = element; node instanceof Element; node = node.parentElement) { + const style = getComputedStyle(node); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { + return false; + } + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +export function formatCockpitBriefAge(value) { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN'; + const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000)); + if (minutes < 60) return `${minutes}M AGO`; + const hours = Math.round(minutes / 60); + return hours < 48 ? `${hours}H AGO` : `${Math.round(hours / 24)}D AGO`; +} + +export function formatCockpitWindDirection(value) { + if (!Number.isFinite(value)) return 'DIR UNKNOWN'; + const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + const normalized = ((value % 360) + 360) % 360; + return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`; +} + +export function setCockpitRollingValue(element, text, numericValue, { + circularRange = null, + immediate = false, +} = {}) { + if (!element) return; + const nextText = String(text); + const previousText = element.dataset.rollingText; + const previousValue = Number(element.dataset.rollingValue); + const nowMs = performance.now(); + const lastRollMs = Number(element.dataset.rollingAt); + if (!immediate + && previousText !== undefined + && previousText !== nextText + && Number.isFinite(lastRollMs) + && nowMs - lastRollMs < 220) { + return; + } + element.dataset.rollingText = nextText; + element.dataset.rollingAt = String(nowMs); + if (Number.isFinite(numericValue)) element.dataset.rollingValue = String(numericValue); + else delete element.dataset.rollingValue; + element.setAttribute('aria-label', nextText); + + if (immediate || previousText === undefined || previousText === nextText) { + if (previousText !== nextText || !element.querySelector('.cockpit-roll-token')) { + element.replaceChildren(...Array.from(nextText, (character) => { + const token = document.createElement('span'); + token.className = 'cockpit-roll-token'; + token.setAttribute('aria-hidden', 'true'); + token.textContent = character; + return token; + })); + } + return; + } + + let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue) + ? numericValue - previousValue + : 0; + if (Number.isFinite(circularRange) && circularRange > 0) { + const halfRange = circularRange / 2; + if (delta > halfRange) delta -= circularRange; + else if (delta < -halfRange) delta += circularRange; + } + const direction = delta < 0 ? 'down' : 'up'; + const width = Math.max(previousText.length, nextText.length); + const from = previousText.padStart(width, ' '); + const to = nextText.padStart(width, ' '); + const fragment = document.createDocumentFragment(); + + for (let index = 0; index < width; index += 1) { + const previousCharacter = from[index]; + const nextCharacter = to[index]; + const token = document.createElement('span'); + token.className = 'cockpit-roll-token'; + token.setAttribute('aria-hidden', 'true'); + + if (previousCharacter === nextCharacter + || !/\d/.test(previousCharacter) + || !/\d/.test(nextCharacter)) { + token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter; + fragment.append(token); + continue; + } + + token.classList.add('is-rolling', `roll-${direction}`); + const track = document.createElement('span'); + track.className = 'cockpit-roll-track'; + const first = document.createElement('span'); + const second = document.createElement('span'); + first.textContent = direction === 'up' ? previousCharacter : nextCharacter; + second.textContent = direction === 'up' ? nextCharacter : previousCharacter; + track.append(first, second); + token.append(track); + fragment.append(token); + } + + element.replaceChildren(fragment); +} diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js new file mode 100644 index 000000000..6229a0172 --- /dev/null +++ b/src/ui/cockpitSignals.js @@ -0,0 +1,174 @@ +/** Reconcile actionable signal rows without disturbing native keyboard focus. */ + + +export function renderCockpitSignals() { + if (!this.signalList) return; + const existing = [...this.signalList.children]; + const focusedEntry = existing.find((entry) => entry.contains(document.activeElement)); + const entriesByKey = new Map(); + for (const entry of existing) { + const key = entry.dataset.signalKey; + if (!entriesByKey.has(key)) entriesByKey.set(key, []); + entriesByKey.get(key).push(entry); + } + const setText = (element, value) => { + if (element.textContent !== value) element.textContent = value; + }; + const entries = this.signalItems.map((item) => { + // Identity includes the action target: a reused status key must never + // silently turn a focused flight button into a different selection. + const key = JSON.stringify([ + item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''), + ]); + let entry = entriesByKey.get(key)?.shift(); + if (!entry) { + entry = document.createElement('li'); + entry.dataset.signalKey = key; + const time = document.createElement('time'); + const body = document.createElement('div'); + const heading = document.createElement(item.target ? 'button' : 'strong'); + if (item.target) { + heading.type = 'button'; + heading.className = 'cockpit-signal-target'; + const label = document.createElement('span'); + label.className = 'cockpit-signal-target-label'; + const rule = document.createElement('span'); + rule.className = 'cockpit-signal-target-rule'; + rule.setAttribute('aria-hidden', 'true'); + const chevron = document.createElement('span'); + chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + chevron.textContent = 'chevron_right'; + heading.append(label, rule, chevron); + } + body.append(heading, document.createElement('span')); + entry.append(time, body); + } + const [time, body] = entry.children; + const [heading, copy] = body.children; + const className = item.target ? `${item.tone} actionable` : item.tone; + if (entry.className !== className) entry.className = className; + setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'); + if (item.target) { + heading.dataset.signalLayer = item.target.layerId; + heading.dataset.signalId = item.target.id; + const label = `Select flight ${item.title}`; + if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label); + setText(heading.children[0], item.title); + } else { + setText(heading, item.title); + } + setText(copy, item.detail); + return entry; + }); + const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null; + if (focusedEntry && !retainedFocus) { + // A departed contact cannot remain selectable. Continue from the stable + // briefing footer instead of dropping keyboard traversal to the page top. + (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true }); + } + for (const entry of existing) if (!entries.includes(entry)) entry.remove(); + + // Ordinary insertBefore moves disconnect an element and lose its focus. + // Reorder the other rows around the focused row, which stays connected. + const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1; + if (retainedFocus) { + let anchor = retainedFocus; + for (let index = focusIndex - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor); + anchor = entry; + } + } + let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild; + for (let index = focusIndex + 1; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === anchor) anchor = anchor.nextElementSibling; + else this.signalList.insertBefore(entry, anchor); + } + this.scheduleContextLayout(); + } + +export function pushCockpitSignal(key, tone, title, detail, target = null) { + if (!this.signalList || !title || !detail) return; + const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; + if (this.signalSignatures.get(key) === signature) return; + this.signalSignatures.set(key, signature); + this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() }); + this.signalItems = this.signalItems.slice(0, 5); + this.renderCockpitSignals(); + } + +export function updateCockpitSignals(snapshot, unknownCount) { + const previous = new Map(this.signalItems.map((item) => [item.key, item])); + const contacts = []; + const subject = snapshot.subject; + if (['flights', 'military'].includes(subject?.layerId) && subject?.id) { + contacts.push({ + key: `flight:${subject.layerId}:${subject.id}`, + tone: 'track', + title: subject.label || subject.id, + detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`, + target: { layerId: subject.layerId, id: String(subject.id) }, + distanceM: -1, + }); + } + for (const cohort of snapshot.cohorts) { + if (!['flights', 'military'].includes(cohort.id)) continue; + for (const item of cohort.nearest) { + const id = item.icao24 || item.id; + if (!id) continue; + contacts.push({ + key: `flight:${cohort.id}:${id}`, + tone: cohort.id === 'military' ? 'nearby military' : 'nearby', + // `id` above is IDENTITY (the ICAO hex). Display must not reuse it: + // the row's own `id` already carries the layer's label convention + // (callsign → registration → hex), so a callsign-less enriched + // contact reads as its registration here too. Same helper the + // Context panel's nearest list uses. + title: this.services.formatAwarenessLabel(item), + detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${ + Number.isFinite(item.distanceM) + ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` + : 'DISTANCE UNKNOWN' + }`, + target: { layerId: cohort.id, id: String(id) }, + distanceM: item.distanceM ?? Infinity, + }); + } + } + contacts.sort((a, b) => a.distanceM - b.distanceM); + const nextItems = contacts.slice(0, 5).map((item) => ({ + ...item, + timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), + })); + if (unknownCount) { + const sources = snapshot.cohorts + .filter((cohort) => cohort.count === null) + .map((cohort) => cohort.source) + .join(' · '); + nextItems.splice(4, Math.max(0, nextItems.length - 4), { + key: 'input-status', + tone: 'warning', + title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`, + detail: sources || 'SOURCE STATUS UNAVAILABLE', + target: null, + timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(), + }); + } + this.signalItems = nextItems; + this.signalSignatures.clear(); + this.renderCockpitSignals(); + } + +export function handleSignalClick(event) { + if (this.destroyed) return; + const target = event.target.closest('button[data-signal-layer][data-signal-id]'); + if (!target) return; + event.preventDefault(); + this.services.militaryAwarenessLayer.focusTarget?.( + target.dataset.signalLayer, + target.dataset.signalId, + { origin: 'user' }, + ); +} diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js new file mode 100644 index 000000000..78ddfc0fc --- /dev/null +++ b/src/ui/cockpitTrackingController.js @@ -0,0 +1,226 @@ +/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */ +import { normalizeHeading, resolveTrackedAircraftInfo } from '../cockpitMath.js'; + +export function readAircraftInfo() { + // In cockpit mode the controller takes the entity off `viewer.trackedEntity` + // (see update()), so the cockpit's own handle is the tracked identity there. + const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; + return resolveTrackedAircraftInfo({ + civilian: this.services.flightsLayer.getTrackedInfo?.() || null, + military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null, + trackedId: trackedEntity?.gevTrackedId || '', + }); + } + +export function dispatchCockpitModeChanged(active, info = null) { + const subjectId = active + ? String(info?.icao24 || '').trim().toLowerCase() || null + : null; + const layerId = active && ['flights', 'military'].includes(info?.layerId) + ? info.layerId + : null; + window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { + detail: { active: active === true, subjectId, layerId }, + })); + } + +export function toggleTrackedTr3b() { + if (this.destroyed) return false; + const info = this.readAircraftInfo(); + const icao24 = String(info?.icao24 || '').trim(); + if (!icao24) return false; + this.services.toggleTr3b(icao24); + const layer = info.layerId === 'military' ? this.services.militaryFlightsLayer : this.services.flightsLayer; + layer.refreshTr3b?.(icao24); + this._tr3bSignature = null; // force the chip to repaint on the next sync + this.syncTr3bToggle(info); + return true; + } + +export function syncTr3bToggle(info) { + if (!this.tr3bToggle) return; + const icao24 = String(info?.icao24 || '').trim(); + const converted = !!icao24 && this.services.isTr3b(icao24); + const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : ''; + if (this._tr3bSignature === signature) return; + this._tr3bSignature = signature; + this.tr3bToggle.hidden = !icao24; + this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); + this.tr3bToggle.title = converted ? 'Restore real aircraft' : 'Reclassify as TR-3B'; + } + +export function syncEntry() { + if (this.destroyed) return false; + if (this.active) return; + const info = this.readAircraftInfo(); + const trackedContact = !!(info && this.viewer.trackedEntity?.position); + this.syncTr3bToggle(trackedContact ? info : null); + const available = !!(this.isEntryAllowed() && trackedContact); + // Change-only DOM writes: this runs on a preUpdate cadence, and + // unconditional `hidden` assignments invalidate style/layout every frame + // even when nothing changed. (perf item 9) + if (this._entryAvailable === available) return; + this._entryAvailable = available; + if (this.entry) this.entry.hidden = !available; + if (this.mapViewButton) this.mapViewButton.hidden = true; + if (this.resetGlobeButton) this.resetGlobeButton.hidden = true; + } + +export function navigateContext(direction, options = {}) { + if (this.destroyed) return false; + const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; + const wasActive = this.active; + if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; + const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options; + const changed = Boolean(this.services.militaryAwarenessLayer?.[method]?.(navigationOptions)); + if (!changed) { + this.contextNavigationDeadlineMs = 0; + return false; + } + if (wasActive) this._adoptTrackedEntity(performance.now()); + return true; + } + +export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { + const nextEntity = this.viewer.trackedEntity; + if (!this.active || !nextEntity?.position || nextEntity === this.trackedEntity) return false; + const info = suppliedInfo || this.readAircraftInfo(); + if (!info) return false; + if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { + this.trackedEntity.show = this.trackedEntityWasShown; + } + this.trackedEntity = nextEntity; + this.trackedEntityWasShown = nextEntity.show; + nextEntity.show = false; + this.viewer.trackedEntity = undefined; + this.cockpitAnchorValid = false; + this.heading = normalizeHeading(info.track ?? 0); + this.lastFrameMs = nowMs; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.lastCameraUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.dispatchCockpitModeChanged(true, info); + return true; + } + +export function enter() { + if (this.destroyed) return false; + if (this.active) return false; + if (!this.isEntryAllowed()) return false; + const info = this.readAircraftInfo(); + const entity = this.viewer.trackedEntity; + if (!info || !entity?.position) return false; + this.entryFocusOrigin = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + // Retire deferred navigation before cancelFlight can run its callbacks. + this.onCameraTakeover?.(); + this.viewer.camera.cancelFlight(); + this.trackedEntity = entity; + this.trackedEntityWasShown = entity.show; + entity.show = false; + this.heading = normalizeHeading(info.track ?? 0); + this.lastFrameMs = performance.now(); + this.lastCameraUpdateMs = 0; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.lastGroundProbeMs = 0; + this.surfaceWaitStartedMs = performance.now(); + this.surfaceAcquiring = false; + this.surfaceFallback = false; + this.lastCompassSignature = ''; + this.cockpitAnchorValid = false; + this.lastCameraUpdateMs = 0; + this.active = true; + // Cockpit animates the camera from preUpdate every frame — preUpdate only + // runs on rendered frames, so idle mode would freeze the cockpit solid. + // (perf wave 2) + this.services.holdContinuousRender('cockpit'); + this.viewer.trackedEntity = undefined; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + document.body.classList.add('cockpit-mode'); + // Activation writes entry/quick/map visibility directly, bypassing + // syncEntry's change-only cache — invalidate it so the exit-path + // syncEntry re-applies every write (notably re-hiding mapViewButton). + this._entryAvailable = undefined; + if (this.entry) this.entry.hidden = true; + if (this.tr3bToggle) { + this.tr3bToggle.hidden = true; + this._tr3bSignature = null; + } + if (this.mapViewButton) this.mapViewButton.hidden = false; + if (this.resetGlobeButton) this.resetGlobeButton.hidden = false; + if (this.hud) this.hud.hidden = false; + if (this.signalStream) this.signalStream.hidden = false; + this.hud?.classList.add('signals-active'); + this.signalItems = []; + this.signalSignatures.clear(); + this.showBriefPage(0); + this.startBriefRotation(); + const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; + const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0'); + this.pushCockpitSignal( + 'track', + 'track', + 'TRACK ACQUIRED', + `${trackLabel} · COURSE ${trackHeading}°`, + ); + this.updateHud(info, performance.now(), true); + this.setVisionMode(this.visionMode); + this.scheduleContextLayout(); + this.mapViewButton?.focus({ preventScroll: true }); + this.onEntered?.(); + this.dispatchCockpitModeChanged(true, info); + return true; + } + +export function exit({ restoreTracking = true } = {}) { + if (!this.active) return false; + const entity = this.trackedEntity; + this.active = false; + this.services.releaseContinuousRender('cockpit'); + this.trackedEntity = null; + this.heading = null; + this.cockpitAnchorValid = false; + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.lastCompassSignature = ''; + this.stopBriefRotation(); + this.regionalBriefAbort?.abort(); + this.regionalBriefAbort = null; + this.regionalBriefRequestToken += 1; + this.regionalBriefSubjectId = null; + document.body.classList.remove('cockpit-mode'); + this.onExited?.(); + this.hud?.style.removeProperty('--cockpit-utility-top'); + this.hud?.style.removeProperty('--cockpit-utility-max-height'); + if (this.hud) this.hud.hidden = true; + if (this.route) this.route.hidden = true; + this.clearPredictiveRoute(); + this.setVisionMode('optical'); + if (this.signalStream) this.signalStream.hidden = true; + this.hud?.classList.remove('signals-active'); + this.viewer.scene.screenSpaceCameraController.enableInputs = true; + if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; + this.trackedEntityWasShown = true; + this.dispatchCockpitModeChanged(false); + if (restoreTracking && entity && this.viewer.entities.contains(entity)) { + this.viewer.trackedEntity = entity; + this.restoreTrackingFrame(entity); + } + this.syncEntry(); + const restoreTarget = this.entryFocusOrigin === this.entry + ? this.entry + : (this.entry || this.entryFocusOrigin); + this.entryFocusOrigin = null; + if (restoreTarget?.isConnected && !restoreTarget.hidden) { + restoreTarget.focus({ preventScroll: true }); + } + return true; + } From e04aefe863c542f2e5db038e5fa96a2828fc94bb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 02:37:44 -0700 Subject: [PATCH 44/92] style: format Cockpit components and pure policies --- scripts/format-scope.json | 19 +- scripts/package-boundaries.json | 4 +- src/cockpitMath.js | 112 ++++-- src/cockpitUtilityLayout.js | 6 +- src/cockpitVisionPolicy.js | 8 +- src/ui/cockpitBriefing.js | 369 ++++++++++-------- src/ui/cockpitCamera.js | 539 +++++++++++++++++---------- src/ui/cockpitContext.js | 281 +++++++------- src/ui/cockpitController.js | 353 +++++++++++++----- src/ui/cockpitController.test.mjs | 94 +++-- src/ui/cockpitDisplayPortal.js | 55 ++- src/ui/cockpitDisplayPortal.test.mjs | 137 +++++-- src/ui/cockpitInput.js | 100 ++--- src/ui/cockpitInstruments.js | 406 ++++++++++++-------- src/ui/cockpitLayout.js | 308 +++++++++------ src/ui/cockpitPresentation.js | 73 ++-- src/ui/cockpitSignals.js | 346 +++++++++-------- src/ui/cockpitTrackingController.js | 424 +++++++++++---------- 18 files changed, 2227 insertions(+), 1407 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 77e87bc49..29b3d5138 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -191,5 +191,22 @@ "src/ui/contextPresentation.js", "src/ui/contextSession.js", "src/ui/contextSubscriptions.js", - "src/ui/contextTransactions.js" + "src/ui/contextTransactions.js", + "src/ui/cockpit.js", + "src/ui/cockpitBriefing.js", + "src/ui/cockpitCamera.js", + "src/ui/cockpitContext.js", + "src/ui/cockpitController.js", + "src/ui/cockpitController.test.mjs", + "src/ui/cockpitDisplayPortal.js", + "src/ui/cockpitDisplayPortal.test.mjs", + "src/ui/cockpitInput.js", + "src/ui/cockpitInstruments.js", + "src/ui/cockpitLayout.js", + "src/ui/cockpitPresentation.js", + "src/ui/cockpitSignals.js", + "src/ui/cockpitTrackingController.js", + "src/cockpitMath.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0cb54b36f..43373f43e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -413,8 +413,6 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] } } diff --git a/src/cockpitMath.js b/src/cockpitMath.js index fc3e2135f..a0bd83676 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -25,8 +25,13 @@ export function slewHeading(current, target, maxStepDeg) { * becoming a first-person surge or reversal while still removing drift. */ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { - if (!Number.isFinite(distanceM) || distanceM <= 0 - || !Number.isFinite(dtSec) || dtSec <= 0) return 0; + if ( + !Number.isFinite(distanceM) || + distanceM <= 0 || + !Number.isFinite(dtSec) || + dtSec <= 0 + ) + return 0; const dt = Math.min(0.1, dtSec); const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; const eased = distanceM * (1 - Math.exp(-1.25 * dt)); @@ -36,14 +41,21 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { /** Return whether a throttled cockpit presentation update is due. */ export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) { - if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) return false; - if (!Number.isFinite(lastUpdateMs) || lastUpdateMs <= 0 || nowMs < lastUpdateMs) return true; + if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) + return false; + if ( + !Number.isFinite(lastUpdateMs) || + lastUpdateMs <= 0 || + nowMs < lastUpdateMs + ) + return true; return nowMs - lastUpdateMs >= intervalMs; } /** Return whether the bounded rendered-surface acquisition window has elapsed. */ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { - if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0) return true; + if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0) + return true; return nowMs - startedMs >= timeoutMs; } @@ -51,7 +63,11 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { * Keep the cockpit camera above the shared rendered-surface floor. * Unknown floors preserve the proposed camera height. */ -export function cockpitGroundSafeHeight(proposedHeightM, groundHeightM, clearanceM) { +export function cockpitGroundSafeHeight( + proposedHeightM, + groundHeightM, + clearanceM, +) { if (!Number.isFinite(proposedHeightM)) return proposedHeightM; if (!Number.isFinite(groundHeightM)) return proposedHeightM; const clearance = Number.isFinite(clearanceM) ? Math.max(0, clearanceM) : 0; @@ -68,36 +84,51 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) { } /** Format the cockpit Context scope without overstating installation coverage. */ -export function formatCockpitContextScope(subjectLabel, radiusM, installationCoverage = null) { - const normalizedLabel = typeof subjectLabel === 'string' - ? subjectLabel.trim() || '—' - : Number.isFinite(subjectLabel) ? String(subjectLabel) : '—'; - const radiusKm = Number.isFinite(radiusM) && radiusM >= 0 - ? String(Math.round(radiusM / 1000)) - : '—'; - const coverage = typeof installationCoverage === 'string' - ? installationCoverage.trim() - : ''; +export function formatCockpitContextScope( + subjectLabel, + radiusM, + installationCoverage = null, +) { + const normalizedLabel = + typeof subjectLabel === 'string' + ? subjectLabel.trim() || '—' + : Number.isFinite(subjectLabel) + ? String(subjectLabel) + : '—'; + const radiusKm = + Number.isFinite(radiusM) && radiusM >= 0 + ? String(Math.round(radiusM / 1000)) + : '—'; + const coverage = + typeof installationCoverage === 'string' ? installationCoverage.trim() : ''; const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`; - return coverage - ? `${base} · INSTALLATIONS ${coverage}` - : base; + return coverage ? `${base} · INSTALLATIONS ${coverage}` : base; } /** Return seven 30-degree compass divisions centered on a heading. */ export function compassDivisions(heading) { const center = Math.round(normalizeHeading(heading) / 30) * 30; - return [-90, -60, -30, 0, 30, 60, 90].map((offset) => normalizeHeading(center + offset)); + return [-90, -60, -30, 0, 30, 60, 90].map((offset) => + normalizeHeading(center + offset), + ); } /** Format a compass division as a cardinal/intercardinal label or degrees. */ export function formatCompassDivision(heading) { const normalized = normalizeHeading(heading); const labels = new Map([ - [0, 'N'], [45, 'NE'], [90, 'E'], [135, 'SE'], - [180, 'S'], [225, 'SW'], [270, 'W'], [315, 'NW'], + [0, 'N'], + [45, 'NE'], + [90, 'E'], + [135, 'SE'], + [180, 'S'], + [225, 'SW'], + [270, 'W'], + [315, 'NW'], ]); - return labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0'); + return ( + labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0') + ); } /** Choose a readable altitude-tape interval for the current flight level. */ @@ -190,20 +221,22 @@ export function formatSpeedRulerTick(valueKt) { /** Return the initial great-circle bearing between two latitude/longitude points. */ export function bearingBetweenCoordinates(fromLat, fromLon, toLat, toLon) { if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return null; - const φ1 = fromLat * Math.PI / 180; - const φ2 = toLat * Math.PI / 180; - const Δλ = (toLon - fromLon) * Math.PI / 180; + const φ1 = (fromLat * Math.PI) / 180; + const φ2 = (toLat * Math.PI) / 180; + const Δλ = ((toLon - fromLon) * Math.PI) / 180; const y = Math.sin(Δλ) * Math.cos(φ2); - const x = Math.cos(φ1) * Math.sin(φ2) - - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); + const x = + Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); if (Math.abs(x) < 1e-12 && Math.abs(y) < 1e-12) return null; - return normalizeHeading(Math.atan2(y, x) * 180 / Math.PI); + return normalizeHeading((Math.atan2(y, x) * 180) / Math.PI); } /** Return a target bearing relative to the current heading in the [-180, 180) range. */ export function relativeBearing(bearing, heading) { if (!Number.isFinite(bearing) || !Number.isFinite(heading)) return null; - return ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180; + return ( + ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180 + ); } /** @@ -229,10 +262,13 @@ export function resolveTrackedAircraftInfo({ if (civilian) candidates.push({ ...civilian, layerId: 'flights' }); if (military) candidates.push({ ...military, layerId: 'military' }); if (!candidates.length) return null; - const trackedKey = String(trackedId || '').trim().toLowerCase(); + const trackedKey = String(trackedId || '') + .trim() + .toLowerCase(); if (trackedKey) { const owner = candidates.find( - (candidate) => `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey, + (candidate) => + `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey, ); if (owner) return owner; } @@ -253,7 +289,10 @@ export function resolveTrackedAircraftInfo({ * aircraftRelative: boolean, contactLost: boolean}} * Panel visibility plus the per-field rendering rules for this frame. */ -export function resolveCockpitContextReadout({ snapshot = null, info = null } = {}) { +export function resolveCockpitContextReadout({ + snapshot = null, + info = null, +} = {}) { if (!snapshot) { return { visible: false, @@ -264,14 +303,15 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } = }; } const trackedId = info?.icao24 ?? info?.id; - const subjectMatchesTracked = snapshot.subject?.layerId === info?.layerId - && String(snapshot.subject?.id) === String(trackedId); + const subjectMatchesTracked = + snapshot.subject?.layerId === info?.layerId && + String(snapshot.subject?.id) === String(trackedId); // `subjectPresent` is absent on snapshots built before the presence signal // existed; only an explicit `false` means the subject left its source. const contactLost = snapshot.subjectPresent === false; return { visible: true, - mode: contactLost ? 'lost' : (subjectMatchesTracked ? 'tracked' : 'foreign'), + mode: contactLost ? 'lost' : subjectMatchesTracked ? 'tracked' : 'foreign', subjectMatchesTracked, // The nose-relative arrow and BRG readout are measured in the tracked // aircraft's own frame; every other value in that row is measured from the diff --git a/src/cockpitUtilityLayout.js b/src/cockpitUtilityLayout.js index dea0a77a3..0d6c09327 100644 --- a/src/cockpitUtilityLayout.js +++ b/src/cockpitUtilityLayout.js @@ -62,7 +62,10 @@ export function resolveCockpitUtilityLayout({ gap = 7, minimumExpandedHeight = 120, }) { - const available = Math.max(minimumExpandedHeight, Number(availableHeight) || 0); + const available = Math.max( + minimumExpandedHeight, + Number(availableHeight) || 0, + ); const expanded = Math.max(0, Number(expandedHeight) || 0); const collapsed = Math.max(0, Number(collapsedHeight) || 0); const spacing = Math.max(0, Number(gap) || 0); @@ -76,4 +79,3 @@ export function resolveCockpitUtilityLayout({ ), }; } - diff --git a/src/cockpitVisionPolicy.js b/src/cockpitVisionPolicy.js index 40602df2e..c49aa35da 100644 --- a/src/cockpitVisionPolicy.js +++ b/src/cockpitVisionPolicy.js @@ -1,4 +1,10 @@ -export const COCKPIT_VISION_MODES = Object.freeze(['optical', 'crt', 'nvg', 'thermal', 'noir']); +export const COCKPIT_VISION_MODES = Object.freeze([ + 'optical', + 'crt', + 'nvg', + 'thermal', + 'noir', +]); const TARGET_STYLE_BY_MODE = Object.freeze({ crt: 'retro', diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index ae777e377..d6f222cfc 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -1,150 +1,199 @@ /** Regional briefing requests, pages and rotation for the Cockpit controller. */ -import { COCKPIT_BRIEF_ROTATE_MS, COCKPIT_BRIEF_CYCLE_OFF_HELP, COCKPIT_BRIEF_CYCLE_ON_HELP, COCKPIT_REGIONAL_REFRESH_MS, COCKPIT_REGIONAL_REFRESH_DISTANCE_M, COCKPIT_BRIEF_PAGES, formatCockpitBriefAge, formatCockpitWindDirection } from './cockpitPresentation.js'; +import { + COCKPIT_BRIEF_ROTATE_MS, + COCKPIT_BRIEF_CYCLE_OFF_HELP, + COCKPIT_BRIEF_CYCLE_ON_HELP, + COCKPIT_REGIONAL_REFRESH_MS, + COCKPIT_REGIONAL_REFRESH_DISTANCE_M, + COCKPIT_BRIEF_PAGES, + formatCockpitBriefAge, + formatCockpitWindDirection, +} from './cockpitPresentation.js'; export function showBriefPage(index, { manual = false } = {}) { - const count = COCKPIT_BRIEF_PAGES.length; - this.briefPageIndex = ((Number(index) % count) + count) % count; - const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; - this.briefPages.forEach((element) => { - element.hidden = element.dataset.cockpitBriefPage !== page.id; - }); - this.briefTabs.forEach((button) => { - const current = Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; - button.setAttribute('aria-current', current ? 'true' : 'false'); - }); - if (this.briefKicker) { - const indicator = this.briefKicker.querySelector('i'); - this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean)); - } - if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle; - if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`; - if (this.briefSource) this.briefSource.textContent = page.source; - if (this.signalStream) this.signalStream.dataset.briefPage = page.id; - if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); - this.scheduleContextLayout(); + const count = COCKPIT_BRIEF_PAGES.length; + this.briefPageIndex = ((Number(index) % count) + count) % count; + const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; + this.briefPages.forEach((element) => { + element.hidden = element.dataset.cockpitBriefPage !== page.id; + }); + this.briefTabs.forEach((button) => { + const current = + Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; + button.setAttribute('aria-current', current ? 'true' : 'false'); + }); + if (this.briefKicker) { + const indicator = this.briefKicker.querySelector('i'); + this.briefKicker.replaceChildren( + ...[indicator, document.createTextNode(` ${page.kicker}`)].filter( + Boolean, + ), + ); } + if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle; + if (this.briefPosition) + this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`; + if (this.briefSource) this.briefSource.textContent = page.source; + if (this.signalStream) this.signalStream.dataset.briefPage = page.id; + if (manual && this.briefAutoRotateEnabled) + this.startBriefRotation({ reset: true }); + this.scheduleContextLayout(); +} export function setBriefAutoRotate(enabled) { - this.briefAutoRotateEnabled = Boolean(enabled); - if (this.briefAutoToggle) { - this.briefAutoToggle.setAttribute('aria-pressed', String(this.briefAutoRotateEnabled)); - const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; - this.briefAutoToggle.textContent = label; - const help = this.briefAutoRotateEnabled - ? COCKPIT_BRIEF_CYCLE_ON_HELP - : COCKPIT_BRIEF_CYCLE_OFF_HELP; - this.briefAutoToggle.setAttribute('aria-label', label); - this.briefAutoToggle.title = help; - } - if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); - else this.stopBriefRotation(); + this.briefAutoRotateEnabled = Boolean(enabled); + if (this.briefAutoToggle) { + this.briefAutoToggle.setAttribute( + 'aria-pressed', + String(this.briefAutoRotateEnabled), + ); + const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; + this.briefAutoToggle.textContent = label; + const help = this.briefAutoRotateEnabled + ? COCKPIT_BRIEF_CYCLE_ON_HELP + : COCKPIT_BRIEF_CYCLE_OFF_HELP; + this.briefAutoToggle.setAttribute('aria-label', label); + this.briefAutoToggle.title = help; } + if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); + else this.stopBriefRotation(); +} export function startBriefRotation({ reset = false } = {}) { - if (reset) this.stopBriefRotation(); - if (!this.briefAutoRotateEnabled - || this.briefTimer - || !this.active - || this.signalCollapsed - || document.hidden) return; - this.briefTimer = window.setTimeout(() => { - this.briefTimer = null; - const hasPointer = this.signalStream?.matches(':hover') === true; - const hasFocus = this.signalStream?.contains(document.activeElement) === true; - const isInteracting = hasPointer || hasFocus; - if (!isInteracting) { - this.showBriefPage(this.briefPageIndex + 1); - } - this.startBriefRotation(); - }, COCKPIT_BRIEF_ROTATE_MS); - } + if (reset) this.stopBriefRotation(); + if ( + !this.briefAutoRotateEnabled || + this.briefTimer || + !this.active || + this.signalCollapsed || + document.hidden + ) + return; + this.briefTimer = window.setTimeout(() => { + this.briefTimer = null; + const hasPointer = this.signalStream?.matches(':hover') === true; + const hasFocus = + this.signalStream?.contains(document.activeElement) === true; + const isInteracting = hasPointer || hasFocus; + if (!isInteracting) { + this.showBriefPage(this.briefPageIndex + 1); + } + this.startBriefRotation(); + }, COCKPIT_BRIEF_ROTATE_MS); +} export function stopBriefRotation() { - if (this.briefTimer) window.clearTimeout(this.briefTimer); - this.briefTimer = null; - } + if (this.briefTimer) window.clearTimeout(this.briefTimer); + this.briefTimer = null; +} export function updateLocalPosition(info) { - if (!this.localCoordinates) return; - if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { - this.localCoordinates.textContent = 'POSITION UNAVAILABLE'; - return; - } - const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; - const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`; - this.localCoordinates.textContent = `${lat} · ${lon}`; + if (!this.localCoordinates) return; + if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { + this.localCoordinates.textContent = 'POSITION UNAVAILABLE'; + return; } + const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; + const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`; + this.localCoordinates.textContent = `${lat} · ${lon}`; +} export function maybeRefreshRegionalBrief(info) { - if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; - if (subjectId !== this.regionalBriefSubjectId) { - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.regionalBriefSubjectId = subjectId; - this.regionalBrief = null; - this.regionalBriefAnchor = null; - this.regionalBriefFetchedAt = 0; - } - const point = { latitude: info.latitude, longitude: info.longitude }; - const ageMs = Date.now() - this.regionalBriefFetchedAt; - const distanceM = this.services.regionalDistanceM(this.regionalBriefAnchor, point); - if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS - && distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return; - - this.regionalBriefAnchor = point; - this.regionalBriefFetchedAt = Date.now(); - const controller = new AbortController(); - const requestToken = ++this.regionalBriefRequestToken; - this.regionalBriefAbort = controller; - if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info); - this.services.fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal }) - .then((payload) => { - if (!this.active - || requestToken !== this.regionalBriefRequestToken - || subjectId !== this.regionalBriefSubjectId) return; - this.regionalBrief = payload; - this.renderRegionalBrief(payload, info); - }) - .catch((error) => { - if (error?.name !== 'AbortError' - && this.active - && requestToken === this.regionalBriefRequestToken - && subjectId === this.regionalBriefSubjectId) { - this.renderRegionalBriefStatus('unavailable', info); - } - }) - .finally(() => { - if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null; - }); + if ( + !this.active || + !Number.isFinite(info.latitude) || + !Number.isFinite(info.longitude) + ) + return; + const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; + if (subjectId !== this.regionalBriefSubjectId) { + this.regionalBriefAbort?.abort(); + this.regionalBriefAbort = null; + this.regionalBriefRequestToken += 1; + this.regionalBriefSubjectId = subjectId; + this.regionalBrief = null; + this.regionalBriefAnchor = null; + this.regionalBriefFetchedAt = 0; } + const point = { latitude: info.latitude, longitude: info.longitude }; + const ageMs = Date.now() - this.regionalBriefFetchedAt; + const distanceM = this.services.regionalDistanceM( + this.regionalBriefAnchor, + point, + ); + if ( + this.regionalBriefAbort || + (ageMs < COCKPIT_REGIONAL_REFRESH_MS && + distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M) + ) + return; + + this.regionalBriefAnchor = point; + this.regionalBriefFetchedAt = Date.now(); + const controller = new AbortController(); + const requestToken = ++this.regionalBriefRequestToken; + this.regionalBriefAbort = controller; + if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info); + this.services + .fetchRegionalBrief(point.latitude, point.longitude, { + signal: controller.signal, + }) + .then((payload) => { + if ( + !this.active || + requestToken !== this.regionalBriefRequestToken || + subjectId !== this.regionalBriefSubjectId + ) + return; + this.regionalBrief = payload; + this.renderRegionalBrief(payload, info); + }) + .catch((error) => { + if ( + error?.name !== 'AbortError' && + this.active && + requestToken === this.regionalBriefRequestToken && + subjectId === this.regionalBriefSubjectId + ) { + this.renderRegionalBriefStatus('unavailable', info); + } + }) + .finally(() => { + if (this.regionalBriefAbort === controller) + this.regionalBriefAbort = null; + }); +} export function renderRegionalBriefStatus(status, info) { - if (this.newsStatus) { - this.newsStatus.hidden = false; - this.newsStatus.dataset.state = status; - this.newsStatus.textContent = status === 'loading' + if (this.newsStatus) { + this.newsStatus.hidden = false; + this.newsStatus.dataset.state = status; + this.newsStatus.textContent = + status === 'loading' ? 'ACQUIRING REGIONAL NEWS' : 'REGIONAL NEWS UNAVAILABLE'; - } - if (status === 'unavailable') this.newsList?.replaceChildren(); - if (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION'; - if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE'; - this.updateLocalPosition(info); } + if (status === 'unavailable') this.newsList?.replaceChildren(); + if (this.localPlace && status === 'loading') + this.localPlace.textContent = 'RESOLVING REGION'; + if (this.localPlace && status === 'unavailable') + this.localPlace.textContent = 'REGION UNAVAILABLE'; + this.updateLocalPosition(info); +} export function renderRegionalBrief(payload, info) { - const articles = Array.isArray(payload?.articles) ? payload.articles : []; - if (this.newsStatus) { - this.newsStatus.hidden = articles.length > 0; - this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; - this.newsStatus.textContent = payload?.newsStatus === 'empty' + const articles = Array.isArray(payload?.articles) ? payload.articles : []; + if (this.newsStatus) { + this.newsStatus.hidden = articles.length > 0; + this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; + this.newsStatus.textContent = + payload?.newsStatus === 'empty' ? 'NO RECENT LOCATION MATCHES' : 'REGIONAL NEWS UNAVAILABLE'; - } - if (this.newsList) { - this.newsList.replaceChildren(...articles.slice(0, 4).map((article) => { + } + if (this.newsList) { + this.newsList.replaceChildren( + ...articles.slice(0, 4).map((article) => { const entry = document.createElement('li'); const link = document.createElement('a'); link.href = article.url; @@ -157,36 +206,50 @@ export function renderRegionalBrief(payload, info) { link.append(title, metadata); entry.append(link); return entry; - })); - } - - const placeLabel = payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE'; - if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); - this.updateLocalPosition(info); - const weather = payload?.weather; - if (this.localTemperature) { - this.localTemperature.textContent = Number.isFinite(weather?.temperatureC) - ? `${Math.round(weather.temperatureC)}°C` : '—'; - } - if (this.localWind) { - this.localWind.textContent = Number.isFinite(weather?.windKph) - ? `${Math.round(weather.windKph)} KM/H` : '—'; - } - if (this.localWindDirection) { - this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg); - } - if (this.localCondition) this.localCondition.textContent = this.services.weatherCodeLabel(weather?.weatherCode); - if (this.localCloud) { - this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) - ? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN'; - } - if (this.localPrecipitation) { - this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm) - ? weather.precipitationMm.toFixed(1) : '—'; - } - if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; - if (this.briefPageIndex === 1 && this.briefSource) { - this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`; - } - this.scheduleContextLayout(); + }), + ); } + + const placeLabel = + payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE'; + if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); + this.updateLocalPosition(info); + const weather = payload?.weather; + if (this.localTemperature) { + this.localTemperature.textContent = Number.isFinite(weather?.temperatureC) + ? `${Math.round(weather.temperatureC)}°C` + : '—'; + } + if (this.localWind) { + this.localWind.textContent = Number.isFinite(weather?.windKph) + ? `${Math.round(weather.windKph)} KM/H` + : '—'; + } + if (this.localWindDirection) { + this.localWindDirection.textContent = formatCockpitWindDirection( + weather?.windDirectionDeg, + ); + } + if (this.localCondition) + this.localCondition.textContent = this.services.weatherCodeLabel( + weather?.weatherCode, + ); + if (this.localCloud) { + this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) + ? `CLOUD ${Math.round(weather.cloudCoverPct)}%` + : 'CLOUD UNKNOWN'; + } + if (this.localPrecipitation) { + this.localPrecipitation.textContent = Number.isFinite( + weather?.precipitationMm, + ) + ? weather.precipitationMm.toFixed(1) + : '—'; + } + if (this.signalStream) + this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; + if (this.briefPageIndex === 1 && this.briefSource) { + this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`; + } + this.scheduleContextLayout(); +} diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 8071fd2b3..c33ba3c15 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -1,225 +1,360 @@ /** Camera placement, ground acquisition and motion correction for the Cockpit controller. */ import * as Cesium from 'cesium'; -import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, slewHeading } from '../cockpitMath.js'; -import { COCKPIT_HEADING_SLEW_DPS, COCKPIT_FORWARD_OFFSET_M, COCKPIT_UP_OFFSET_M, COCKPIT_MIN_GROUND_CLEARANCE_M, COCKPIT_VIEW_PITCH_DEG, COCKPIT_CAMERA_UPDATE_MS, COCKPIT_HUD_UPDATE_MS, COCKPIT_GROUND_PROBE_MS, COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js'; +import { + cockpitAnchorCorrectionStep, + cockpitGroundSafeHeight, + cockpitSurfaceWaitExpired, + cockpitUiUpdateDue, + slewHeading, +} from '../cockpitMath.js'; +import { + COCKPIT_HEADING_SLEW_DPS, + COCKPIT_FORWARD_OFFSET_M, + COCKPIT_UP_OFFSET_M, + COCKPIT_MIN_GROUND_CLEARANCE_M, + COCKPIT_VIEW_PITCH_DEG, + COCKPIT_CAMERA_UPDATE_MS, + COCKPIT_HUD_UPDATE_MS, + COCKPIT_GROUND_PROBE_MS, + COCKPIT_GROUND_WAIT_TIMEOUT_MS, +} from './cockpitPresentation.js'; export function update() { - if (this.destroyed) return false; - if (!this.active) { - // Entry availability changes on a human timescale (tracking start/stop, - // info arriving after a poll) — polling it every rendered frame ran - // readAircraftInfo() + DOM pokes at display rate in plain map mode. - // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still - // fires syncEntry immediately on the events that matter. (perf item 9) - const nowMs = performance.now(); - if (nowMs - (this._lastEntrySyncMs || 0) >= 250) { - this._lastEntrySyncMs = nowMs; - this.syncEntry(); - } - return; - } - + if (this.destroyed) return false; + if (!this.active) { + // Entry availability changes on a human timescale (tracking start/stop, + // info arriving after a poll) — polling it every rendered frame ran + // readAircraftInfo() + DOM pokes at display rate in plain map mode. + // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still + // fires syncEntry immediately on the events that matter. (perf item 9) const nowMs = performance.now(); - const info = this.readAircraftInfo(); - - // Adopt a newly selected track before the camera cadence gate. A context - // NEXT/PREV selection can otherwise spend one frame driving the old - // aircraft with the new aircraft's metadata. - this._adoptTrackedEntity(nowMs, info); - if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) { - if (nowMs < this.contextNavigationDeadlineMs) return; - this.exit({ restoreTracking: false }); - return; + if (nowMs - (this._lastEntrySyncMs || 0) >= 250) { + this._lastEntrySyncMs = nowMs; + this.syncEntry(); } - if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return; - this.lastCameraUpdateMs = nowMs; + return; + } - const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget); - if (!target) return; - const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000)); - this.lastFrameMs = nowMs; - if (Number.isFinite(info.track)) { - this.heading = slewHeading( - this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec, - ); - } + const nowMs = performance.now(); + const info = this.readAircraftInfo(); - if (!this.cockpitAnchorValid) { - Cesium.Cartesian3.clone(target, this.cockpitAnchor); - this.cockpitAnchorValid = true; - } + // Adopt a newly selected track before the camera cadence gate. A context + // NEXT/PREV selection can otherwise spend one frame driving the old + // aircraft with the new aircraft's metadata. + this._adoptTrackedEntity(nowMs, info); + if ( + !info || + !this.trackedEntity || + !this.viewer.entities.contains(this.trackedEntity) + ) { + if (nowMs < this.contextNavigationDeadlineMs) return; + this.exit({ restoreTracking: false }); + return; + } + if ( + !cockpitUiUpdateDue( + nowMs, + this.lastCameraUpdateMs, + COCKPIT_CAMERA_UPDATE_MS, + ) + ) + return; + this.lastCameraUpdateMs = nowMs; - // First-person motion cannot use the delayed feed correction as a raw - // camera destination: a harmless icon re-anchor becomes a whole-world - // surge/reversal in cockpit view. Advance the camera anchor inertially - // from the reported course/speed and converge on the authoritative layer - // display position at a bounded rate. This preserves the layer's required - // 15/30-second interpolation and per-frame cache without exposing its - // sample-boundary corrections to the camera. - const headingRad = Cesium.Math.toRadians(this.heading ?? 0); - const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); - const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; - - if (info.stale) { - // A feed backoff has no authoritative velocity epoch to advance from. - // Hold the cockpit on the exact layer-rendered position so the camera - // cannot coast away while the icon correctly remains fixed. - Cesium.Cartesian3.clone(target, this.cockpitAnchor); - } else { - Cesium.Transforms.eastNorthUpToFixedFrame( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, - ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); - this.scratchLocal.z = 0; - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); - Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - Cesium.Cartesian3.multiplyByScalar( - this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchAdvance, this.cockpitAnchor); - Cesium.Cartesian3.subtract(target, this.cockpitAnchor, this.scratchCorrection); - const correctionDistanceM = Cesium.Cartesian3.magnitude(this.scratchCorrection); - const correctionStepM = cockpitAnchorCorrectionStep(correctionDistanceM, speedMps, dtSec); - if (correctionStepM > 0 && correctionDistanceM > 0) { - Cesium.Cartesian3.multiplyByScalar( - this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection, - ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor); - } - } - - // The inertial anchor is independent of the layer's render-floor clamp and - // can otherwise coast into a photoreal mesh while a landing contact is - // between fixes. Clamp it against the same mesh-first shared floor used by - // aircraft rendering. For a slow contact whose floor cell is still cold, - // its already-clamped render position is a conservative temporary floor. - const anchorCartographic = Cesium.Cartographic.fromCartesian( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic, + const target = this.trackedEntity.position.getValue( + this.viewer.clock.currentTime, + this.scratchTarget, + ); + if (!target) return; + const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000)); + this.lastFrameMs = nowMs; + if (Number.isFinite(info.track)) { + this.heading = slewHeading( + this.heading ?? info.track, + info.track, + COCKPIT_HEADING_SLEW_DPS * dtSec, ); - const targetCartographic = Cesium.Cartographic.fromCartesian( - target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic, - ); - let cockpitFloorM = this.services.cachedGroundFloor(info.latitude, info.longitude); - if (info.onGround === true) { - const groundPoint = [{ lat: info.latitude, lon: info.longitude }]; - this.services.warmGroundFloor(groundPoint); - const meshFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude); - if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) { - if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) { - this.lastGroundProbeMs = nowMs; - const viewerCartographic = this.viewer.camera.positionCartographic; - this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, { - excludeObjects: [this.trackedEntity], - viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude), - viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude), - }); - } - cockpitFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude); - if (!Number.isFinite(cockpitFloorM)) { - if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; - if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) { - // Keep the already-safe map camera in place while the photoreal - // surface under a parked aircraft is acquired. The bounded wait - // prevents a permanently cold mesh cell from freezing cockpit. - this.surfaceAcquiring = true; - this.surfaceFallback = false; - if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { - this.lastHudUpdateMs = nowMs; - this.updateHud(info, nowMs); - } - return; - } - this.surfaceAcquiring = false; - this.surfaceFallback = true; - } else { - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - } - } - } else { - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - } - if (!Number.isFinite(cockpitFloorM) - && speedMps < 90 - && Number.isFinite(targetCartographic?.height)) { - cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M; - } - if (anchorCartographic && Number.isFinite(cockpitFloorM)) { - const minimumAnchorHeightM = cockpitGroundSafeHeight( - anchorCartographic.height, - cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, - ); - if (minimumAnchorHeightM !== anchorCartographic.height) { - anchorCartographic.height = minimumAnchorHeightM; - Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor); - } - } + } - // Rebuild the local frame at the stabilized anchor after advancing it. + if (!this.cockpitAnchorValid) { + Cesium.Cartesian3.clone(target, this.cockpitAnchor); + this.cockpitAnchorValid = true; + } + + // First-person motion cannot use the delayed feed correction as a raw + // camera destination: a harmless icon re-anchor becomes a whole-world + // surge/reversal in cockpit view. Advance the camera anchor inertially + // from the reported course/speed and converge on the authoritative layer + // display position at a bounded rate. This preserves the layer's required + // 15/30-second interpolation and per-frame cache without exposing its + // sample-boundary corrections to the camera. + const headingRad = Cesium.Math.toRadians(this.heading ?? 0); + const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); + const speedMps = Number.isFinite(info.velocityMps) + ? Math.max(0, info.velocityMps) + : 0; + + if (info.stale) { + // A feed backoff has no authoritative velocity epoch to advance from. + // Hold the cockpit on the exact layer-rendered position so the camera + // cannot coast away while the icon correctly remains fixed. + Cesium.Cartesian3.clone(target, this.cockpitAnchor); + } else { Cesium.Transforms.eastNorthUpToFixedFrame( - this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, + this.cockpitAnchor, + Cesium.Ellipsoid.WGS84, + this.scratchEnu, ); this.scratchLocal.x = Math.sin(headingRad); this.scratchLocal.y = Math.cos(headingRad); this.scratchLocal.z = 0; - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); + Cesium.Matrix4.multiplyByPointAsVector( + this.scratchEnu, + this.scratchLocal, + this.scratchHorizontal, + ); Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - - this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); - this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); - this.scratchLocal.z = Math.sin(pitchRad); - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward); - Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); - - this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); - this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); - this.scratchLocal.z = Math.cos(pitchRad); - Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp); - Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp); - Cesium.Cartesian3.multiplyByScalar( - this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera, + this.scratchHorizontal, + speedMps * dtSec, + this.scratchAdvance, ); - Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera); - Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget); - Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); - Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget); - Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera); - - // Recheck at the final forward-offset camera coordinate because a taxiing - // aircraft can cross into an adjacent coarse floor cell between updates. - const cameraCartographic = Cesium.Cartographic.fromCartesian( - this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, + Cesium.Cartesian3.add( + this.cockpitAnchor, + this.scratchAdvance, + this.cockpitAnchor, ); - if (cameraCartographic) { - const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); - const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); - const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon); - if (Number.isFinite(cameraFloorM)) { - cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM); - } - const safeHeightM = cockpitGroundSafeHeight( - cameraCartographic.height, - cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M, + Cesium.Cartesian3.subtract( + target, + this.cockpitAnchor, + this.scratchCorrection, + ); + const correctionDistanceM = Cesium.Cartesian3.magnitude( + this.scratchCorrection, + ); + const correctionStepM = cockpitAnchorCorrectionStep( + correctionDistanceM, + speedMps, + dtSec, + ); + if (correctionStepM > 0 && correctionDistanceM > 0) { + Cesium.Cartesian3.multiplyByScalar( + this.scratchCorrection, + correctionStepM / correctionDistanceM, + this.scratchCorrection, + ); + Cesium.Cartesian3.add( + this.cockpitAnchor, + this.scratchCorrection, + this.cockpitAnchor, ); - if (safeHeightM !== cameraCartographic.height) { - cameraCartographic.height = safeHeightM; - Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera); - } - } - - this.viewer.camera.setView({ - destination: this.scratchCamera, - orientation: { direction: this.scratchForward, up: this.scratchUp }, - }); - if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { - this.lastHudUpdateMs = nowMs; - this.updateHud(info, nowMs); } } + + // The inertial anchor is independent of the layer's render-floor clamp and + // can otherwise coast into a photoreal mesh while a landing contact is + // between fixes. Clamp it against the same mesh-first shared floor used by + // aircraft rendering. For a slow contact whose floor cell is still cold, + // its already-clamped render position is a conservative temporary floor. + const anchorCartographic = Cesium.Cartographic.fromCartesian( + this.cockpitAnchor, + Cesium.Ellipsoid.WGS84, + this.scratchAnchorCartographic, + ); + const targetCartographic = Cesium.Cartographic.fromCartesian( + target, + Cesium.Ellipsoid.WGS84, + this.scratchTargetCartographic, + ); + let cockpitFloorM = this.services.cachedGroundFloor( + info.latitude, + info.longitude, + ); + if (info.onGround === true) { + const groundPoint = [{ lat: info.latitude, lon: info.longitude }]; + this.services.warmGroundFloor(groundPoint); + const meshFloorM = this.services.cachedMeshFloor( + info.latitude, + info.longitude, + ); + if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) { + if ( + cockpitUiUpdateDue( + nowMs, + this.lastGroundProbeMs, + COCKPIT_GROUND_PROBE_MS, + ) + ) { + this.lastGroundProbeMs = nowMs; + const viewerCartographic = this.viewer.camera.positionCartographic; + this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, { + excludeObjects: [this.trackedEntity], + viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude), + viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude), + }); + } + cockpitFloorM = this.services.cachedMeshFloor( + info.latitude, + info.longitude, + ); + if (!Number.isFinite(cockpitFloorM)) { + if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; + if ( + !cockpitSurfaceWaitExpired( + nowMs, + this.surfaceWaitStartedMs, + COCKPIT_GROUND_WAIT_TIMEOUT_MS, + ) + ) { + // Keep the already-safe map camera in place while the photoreal + // surface under a parked aircraft is acquired. The bounded wait + // prevents a permanently cold mesh cell from freezing cockpit. + this.surfaceAcquiring = true; + this.surfaceFallback = false; + if ( + cockpitUiUpdateDue( + nowMs, + this.lastHudUpdateMs, + COCKPIT_HUD_UPDATE_MS, + ) + ) { + this.lastHudUpdateMs = nowMs; + this.updateHud(info, nowMs); + } + return; + } + this.surfaceAcquiring = false; + this.surfaceFallback = true; + } else { + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + } + } + } else { + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + } + if ( + !Number.isFinite(cockpitFloorM) && + speedMps < 90 && + Number.isFinite(targetCartographic?.height) + ) { + cockpitFloorM = + targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M; + } + if (anchorCartographic && Number.isFinite(cockpitFloorM)) { + const minimumAnchorHeightM = cockpitGroundSafeHeight( + anchorCartographic.height, + cockpitFloorM, + COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, + ); + if (minimumAnchorHeightM !== anchorCartographic.height) { + anchorCartographic.height = minimumAnchorHeightM; + Cesium.Ellipsoid.WGS84.cartographicToCartesian( + anchorCartographic, + this.cockpitAnchor, + ); + } + } + + // Rebuild the local frame at the stabilized anchor after advancing it. + Cesium.Transforms.eastNorthUpToFixedFrame( + this.cockpitAnchor, + Cesium.Ellipsoid.WGS84, + this.scratchEnu, + ); + this.scratchLocal.x = Math.sin(headingRad); + this.scratchLocal.y = Math.cos(headingRad); + this.scratchLocal.z = 0; + Cesium.Matrix4.multiplyByPointAsVector( + this.scratchEnu, + this.scratchLocal, + this.scratchHorizontal, + ); + Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); + + this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); + this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); + this.scratchLocal.z = Math.sin(pitchRad); + Cesium.Matrix4.multiplyByPointAsVector( + this.scratchEnu, + this.scratchLocal, + this.scratchForward, + ); + Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); + + this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); + this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); + this.scratchLocal.z = Math.cos(pitchRad); + Cesium.Matrix4.multiplyByPointAsVector( + this.scratchEnu, + this.scratchLocal, + this.scratchUp, + ); + Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp); + + Cesium.Cartesian3.multiplyByScalar( + this.scratchHorizontal, + COCKPIT_FORWARD_OFFSET_M, + this.scratchCamera, + ); + Cesium.Cartesian3.add( + this.cockpitAnchor, + this.scratchCamera, + this.scratchCamera, + ); + Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget); + Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); + Cesium.Cartesian3.multiplyByScalar( + this.scratchTarget, + COCKPIT_UP_OFFSET_M, + this.scratchTarget, + ); + Cesium.Cartesian3.add( + this.scratchCamera, + this.scratchTarget, + this.scratchCamera, + ); + + // Recheck at the final forward-offset camera coordinate because a taxiing + // aircraft can cross into an adjacent coarse floor cell between updates. + const cameraCartographic = Cesium.Cartographic.fromCartesian( + this.scratchCamera, + Cesium.Ellipsoid.WGS84, + this.scratchCameraCartographic, + ); + if (cameraCartographic) { + const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); + const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); + const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon); + if (Number.isFinite(cameraFloorM)) { + cockpitFloorM = Math.max( + cockpitFloorM ?? Number.NEGATIVE_INFINITY, + cameraFloorM, + ); + } + const safeHeightM = cockpitGroundSafeHeight( + cameraCartographic.height, + cockpitFloorM, + COCKPIT_MIN_GROUND_CLEARANCE_M, + ); + if (safeHeightM !== cameraCartographic.height) { + cameraCartographic.height = safeHeightM; + Cesium.Ellipsoid.WGS84.cartographicToCartesian( + cameraCartographic, + this.scratchCamera, + ); + } + } + + this.viewer.camera.setView({ + destination: this.scratchCamera, + orientation: { direction: this.scratchForward, up: this.scratchUp }, + }); + if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { + this.lastHudUpdateMs = nowMs; + this.updateHud(info, nowMs); + } +} diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index b62445598..70dac99f7 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,134 +1,157 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; -import { bearingBetweenCoordinates, formatCockpitContextScope, relativeBearing, resolveCockpitContextReadout } from '../cockpitMath.js'; +import { + bearingBetweenCoordinates, + formatCockpitContextScope, + relativeBearing, + resolveCockpitContextReadout, +} from '../cockpitMath.js'; export function updateContext(info, heading) { - if (!this.context) return; - const snapshot = this.services.militaryAwarenessLayer.getContextSnapshot?.() || null; - const trackedId = info.icao24 || info.id; - const readout = resolveCockpitContextReadout({ snapshot, info }); - if (!readout.visible) { - this.context.hidden = true; - this.hud?.classList.remove('context-active'); - this.contextLayoutStamp = null; - this.pushCockpitSignal( - 'context-status', - 'info', - 'CONTEXT STANDBY', - 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', - ); - return; - } - - this.context.hidden = false; - this.hud?.classList.add('context-active'); - if (this.contextSubject) { - const installationCoverage = snapshot.cohorts - .find((cohort) => cohort.id === 'military-installations')?.coverage; - this.contextSubject.textContent = formatCockpitContextScope( - snapshot.subject.label || trackedId, - snapshot.radiusM, - installationCoverage, - ); - } - // Navigation stays wired in every state — the operator must always be able - // to step off the current contact from the panel that hosts the controls. - if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; - if (this.contextNext) this.contextNext.disabled = !snapshot.navigation?.canNext; - - if (readout.contactLost) { - // The subject left its source. Every number below is measured against a - // position that stopped updating, so hold the last rendered readout and - // say so instead of re-deriving stale geometry as if it were live. - const enteringLost = this.context.dataset.state !== 'lost'; - this.context.dataset.state = 'lost'; - if (this.contextUncertainty) { - this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; - } - // The cue changes the footer's height; re-run layout once on the way in - // rather than every frame the contact stays lost. - if (enteringLost) this.scheduleContextLayout(); - this.pushCockpitSignal( - 'context-status', - 'warning', - `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`, - 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', - ); - return; - } - - let unknownCount = 0; - const nearest = []; - for (const cohort of snapshot.cohorts) { - const element = this.contextCohorts.get(cohort.id); - const value = element?.querySelector('strong'); - if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count); - element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); - if (cohort.count === null) unknownCount += 1; - for (const item of cohort.nearest) nearest.push({ ...item, cohort }); - } - nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity)); - const closest = nearest[0] || null; - const closestLabel = this.services.formatAwarenessLabel(closest); - if (this.contextNearestLabel) { - this.contextNearestLabel.textContent = closest - ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE'; - this.contextNearestLabel.setAttribute( - 'aria-label', - closest && closestLabel === '—' - ? `${closest.cohort.label}, Unavailable` - : this.contextNearestLabel.textContent, - ); - } - if (this.contextDistance) { - const distanceM = closest?.distanceM; - this.contextDistance.textContent = Number.isFinite(distanceM) - ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—'; - this.contextDistance.setAttribute( - 'aria-label', - Number.isFinite(distanceM) ? this.contextDistance.textContent : 'Unavailable', - ); - } - - // The arrow and BRG are nose-relative to the tracked aircraft. When the - // subject is a vessel, an installation, or another aircraft, the rest of - // this row is measured from that subject — so the aircraft-frame half is - // dashed rather than presented alongside subject-frame distances as if the - // two shared an origin. - let relative = null; - if (readout.aircraftRelative - && closest?.position && Number.isFinite(info.latitude) && Number.isFinite(info.longitude)) { - const cartographic = Cesium.Cartographic.fromCartesian(closest.position); - const bearing = cartographic ? bearingBetweenCoordinates( - info.latitude, - info.longitude, - Cesium.Math.toDegrees(cartographic.latitude), - Cesium.Math.toDegrees(cartographic.longitude), - ) : null; - relative = relativeBearing(bearing, heading); - } - if (this.contextDirection) { - this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`; - this.contextDirection.classList.toggle('unknown', relative === null); - } - if (this.contextBearing) { - if (relative === null) this.contextBearing.textContent = 'BRG —'; - else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; - else this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`; - } - if (this.contextUncertainty) { - this.contextUncertainty.textContent = unknownCount - ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR` - : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'; - } - if (this.contextUpdated) { - this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) - ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; - } - this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; - this.updateCockpitSignals(snapshot, unknownCount); - if (this.contextLayoutStamp !== snapshot.evaluatedAt) { - this.contextLayoutStamp = snapshot.evaluatedAt; - this.scheduleContextLayout(); - } + if (!this.context) return; + const snapshot = + this.services.militaryAwarenessLayer.getContextSnapshot?.() || null; + const trackedId = info.icao24 || info.id; + const readout = resolveCockpitContextReadout({ snapshot, info }); + if (!readout.visible) { + this.context.hidden = true; + this.hud?.classList.remove('context-active'); + this.contextLayoutStamp = null; + this.pushCockpitSignal( + 'context-status', + 'info', + 'CONTEXT STANDBY', + 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', + ); + return; } + + this.context.hidden = false; + this.hud?.classList.add('context-active'); + if (this.contextSubject) { + const installationCoverage = snapshot.cohorts.find( + (cohort) => cohort.id === 'military-installations', + )?.coverage; + this.contextSubject.textContent = formatCockpitContextScope( + snapshot.subject.label || trackedId, + snapshot.radiusM, + installationCoverage, + ); + } + // Navigation stays wired in every state — the operator must always be able + // to step off the current contact from the panel that hosts the controls. + if (this.contextPrevious) + this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; + if (this.contextNext) + this.contextNext.disabled = !snapshot.navigation?.canNext; + + if (readout.contactLost) { + // The subject left its source. Every number below is measured against a + // position that stopped updating, so hold the last rendered readout and + // say so instead of re-deriving stale geometry as if it were live. + const enteringLost = this.context.dataset.state !== 'lost'; + this.context.dataset.state = 'lost'; + if (this.contextUncertainty) { + this.contextUncertainty.textContent = + 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; + } + // The cue changes the footer's height; re-run layout once on the way in + // rather than every frame the contact stays lost. + if (enteringLost) this.scheduleContextLayout(); + this.pushCockpitSignal( + 'context-status', + 'warning', + `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`, + 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', + ); + return; + } + + let unknownCount = 0; + const nearest = []; + for (const cohort of snapshot.cohorts) { + const element = this.contextCohorts.get(cohort.id); + const value = element?.querySelector('strong'); + if (value) + value.textContent = cohort.count === null ? '?' : String(cohort.count); + element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); + if (cohort.count === null) unknownCount += 1; + for (const item of cohort.nearest) nearest.push({ ...item, cohort }); + } + nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity)); + const closest = nearest[0] || null; + const closestLabel = this.services.formatAwarenessLabel(closest); + if (this.contextNearestLabel) { + this.contextNearestLabel.textContent = closest + ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` + : 'NO AVAILABLE EXAMPLE'; + this.contextNearestLabel.setAttribute( + 'aria-label', + closest && closestLabel === '—' + ? `${closest.cohort.label}, Unavailable` + : this.contextNearestLabel.textContent, + ); + } + if (this.contextDistance) { + const distanceM = closest?.distanceM; + this.contextDistance.textContent = Number.isFinite(distanceM) + ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` + : '—'; + this.contextDistance.setAttribute( + 'aria-label', + Number.isFinite(distanceM) + ? this.contextDistance.textContent + : 'Unavailable', + ); + } + + // The arrow and BRG are nose-relative to the tracked aircraft. When the + // subject is a vessel, an installation, or another aircraft, the rest of + // this row is measured from that subject — so the aircraft-frame half is + // dashed rather than presented alongside subject-frame distances as if the + // two shared an origin. + let relative = null; + if ( + readout.aircraftRelative && + closest?.position && + Number.isFinite(info.latitude) && + Number.isFinite(info.longitude) + ) { + const cartographic = Cesium.Cartographic.fromCartesian(closest.position); + const bearing = cartographic + ? bearingBetweenCoordinates( + info.latitude, + info.longitude, + Cesium.Math.toDegrees(cartographic.latitude), + Cesium.Math.toDegrees(cartographic.longitude), + ) + : null; + relative = relativeBearing(bearing, heading); + } + if (this.contextDirection) { + this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`; + this.contextDirection.classList.toggle('unknown', relative === null); + } + if (this.contextBearing) { + if (relative === null) this.contextBearing.textContent = 'BRG —'; + else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; + else + this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`; + } + if (this.contextUncertainty) { + this.contextUncertainty.textContent = unknownCount + ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR` + : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'; + } + if (this.contextUpdated) { + this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) + ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' + : '--:--:--Z'; + } + this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; + this.updateCockpitSignals(snapshot, unknownCount); + if (this.contextLayoutStamp !== snapshot.evaluatedAt) { + this.contextLayoutStamp = snapshot.evaluatedAt; + this.scheduleContextLayout(); + } +} diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 48d09fb79..4f5cc33fc 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -1,25 +1,65 @@ /** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */ -import { readAircraftInfo, dispatchCockpitModeChanged, toggleTrackedTr3b, syncTr3bToggle, syncEntry, navigateContext, _adoptTrackedEntity, enter, exit } from './cockpitTrackingController.js'; +import { + readAircraftInfo, + dispatchCockpitModeChanged, + toggleTrackedTr3b, + syncTr3bToggle, + syncEntry, + navigateContext, + _adoptTrackedEntity, + enter, + exit, +} from './cockpitTrackingController.js'; import { update } from './cockpitCamera.js'; -import { updateHud, updateRoute, syncWeatherToggle, setVisionMode, cycleVisionMode, clearPredictiveRoute } from './cockpitInstruments.js'; +import { + updateHud, + updateRoute, + syncWeatherToggle, + setVisionMode, + cycleVisionMode, + clearPredictiveRoute, +} from './cockpitInstruments.js'; import { updateContext } from './cockpitContext.js'; -import { showBriefPage, setBriefAutoRotate, startBriefRotation, stopBriefRotation, updateLocalPosition, maybeRefreshRegionalBrief, renderRegionalBriefStatus, renderRegionalBrief } from './cockpitBriefing.js'; -import { handleSignalClick, renderCockpitSignals, pushCockpitSignal, updateCockpitSignals } from './cockpitSignals.js'; -import { scheduleContextLayout, setContextCollapsed, setSignalCollapsed, syncContextLayout, syncSignalLayout } from './cockpitLayout.js'; +import { + showBriefPage, + setBriefAutoRotate, + startBriefRotation, + stopBriefRotation, + updateLocalPosition, + maybeRefreshRegionalBrief, + renderRegionalBriefStatus, + renderRegionalBrief, +} from './cockpitBriefing.js'; +import { + handleSignalClick, + renderCockpitSignals, + pushCockpitSignal, + updateCockpitSignals, +} from './cockpitSignals.js'; +import { + scheduleContextLayout, + setContextCollapsed, + setSignalCollapsed, + syncContextLayout, + syncSignalLayout, +} from './cockpitLayout.js'; import { onKeyDown } from './cockpitInput.js'; import * as Cesium from 'cesium'; export class CockpitViewController { - constructor(viewer, { - services, - onVisionChange = null, - onCameraTakeover = null, - isEntryAllowed = null, - onEntered = null, - onExited = null, - getInheritedVisionLabel = null, - restoreTrackingFrame = null, - } = {}) { + constructor( + viewer, + { + services, + onVisionChange = null, + onCameraTakeover = null, + isEntryAllowed = null, + onEntered = null, + onExited = null, + getInheritedVisionLabel = null, + restoreTrackingFrame = null, + } = {}, + ) { this.viewer = viewer; this.services = services; this.destroyed = false; @@ -48,11 +88,17 @@ export class CockpitViewController { this.speed = document.getElementById('cockpit-speed-value'); this.speedRim = document.getElementById('cockpit-speed-rim'); this.speedRimValue = document.getElementById('cockpit-speed-rim-value'); - this.speedRimTicks = Array.from(document.querySelectorAll('[data-speed-rim-tick]')); + this.speedRimTicks = Array.from( + document.querySelectorAll('[data-speed-rim-tick]'), + ); this.altitude = document.getElementById('cockpit-altitude-value'); this.altitudeRim = document.getElementById('cockpit-altitude-rim'); - this.altitudeRimValue = document.getElementById('cockpit-altitude-rim-value'); - this.altitudeRimTicks = Array.from(document.querySelectorAll('[data-altitude-rim-tick]')); + this.altitudeRimValue = document.getElementById( + 'cockpit-altitude-rim-value', + ); + this.altitudeRimTicks = Array.from( + document.querySelectorAll('[data-altitude-rim-tick]'), + ); this.headingValue = document.getElementById('cockpit-heading-value'); this.compassTape = document.getElementById('cockpit-compass-tape'); this.clock = document.getElementById('cockpit-clock'); @@ -63,33 +109,49 @@ export class CockpitViewController { this.routeTo = document.getElementById('cockpit-route-to'); this.routeStatus = document.getElementById('cockpit-route-status'); this.routeDirection = document.getElementById('cockpit-route-direction'); - this.routeDirectionLabel = document.getElementById('cockpit-route-direction-label'); + this.routeDirectionLabel = document.getElementById( + 'cockpit-route-direction-label', + ); this.visionPrevious = document.getElementById('cockpit-vision-previous'); this.visionCurrent = document.getElementById('cockpit-vision-current'); - this.visionCurrentLabel = document.getElementById('cockpit-vision-current-label'); + this.visionCurrentLabel = document.getElementById( + 'cockpit-vision-current-label', + ); this.visionNext = document.getElementById('cockpit-vision-next'); this.visionMode = 'optical'; this.onVisionChange = onVisionChange; this.onCameraTakeover = onCameraTakeover; this.onEntered = onEntered; this.onExited = onExited; - this.getInheritedVisionLabel = typeof getInheritedVisionLabel === 'function' - ? getInheritedVisionLabel - : () => 'NORMAL'; - this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function' - ? restoreTrackingFrame - : () => false; - this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; + this.getInheritedVisionLabel = + typeof getInheritedVisionLabel === 'function' + ? getInheritedVisionLabel + : () => 'NORMAL'; + this.restoreTrackingFrame = + typeof restoreTrackingFrame === 'function' + ? restoreTrackingFrame + : () => false; + this.isEntryAllowed = + typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; this.context = document.getElementById('cockpit-context'); this.contextSubject = document.getElementById('cockpit-context-subject'); - this.contextNearestLabel = document.getElementById('cockpit-context-nearest-label'); + this.contextNearestLabel = document.getElementById( + 'cockpit-context-nearest-label', + ); this.contextBearing = document.getElementById('cockpit-context-bearing'); this.contextDistance = document.getElementById('cockpit-context-distance'); - this.contextDirection = document.getElementById('cockpit-context-direction'); - this.contextUncertainty = document.getElementById('cockpit-context-uncertainty'); + this.contextDirection = document.getElementById( + 'cockpit-context-direction', + ); + this.contextUncertainty = document.getElementById( + 'cockpit-context-uncertainty', + ); this.contextUpdated = document.getElementById('cockpit-context-updated'); - this.contextCohorts = new Map(Array.from(document.querySelectorAll('[data-context-cohort]')) - .map((element) => [element.dataset.contextCohort, element])); + this.contextCohorts = new Map( + Array.from(document.querySelectorAll('[data-context-cohort]')).map( + (element) => [element.dataset.contextCohort, element], + ), + ); this.contextPrevious = document.getElementById('cockpit-context-previous'); this.contextNext = document.getElementById('cockpit-context-next'); this.contextToggle = document.getElementById('cockpit-context-toggle'); @@ -106,18 +168,30 @@ export class CockpitViewController { this.briefAutoToggle = document.getElementById('cockpit-brief-auto'); this.briefPosition = document.getElementById('cockpit-brief-position'); this.briefSource = document.getElementById('cockpit-brief-source'); - this.briefPages = Array.from(document.querySelectorAll('[data-cockpit-brief-page]')); - this.briefTabs = Array.from(document.querySelectorAll('[data-cockpit-brief-index]')); + this.briefPages = Array.from( + document.querySelectorAll('[data-cockpit-brief-page]'), + ); + this.briefTabs = Array.from( + document.querySelectorAll('[data-cockpit-brief-index]'), + ); this.newsStatus = document.getElementById('cockpit-news-status'); this.newsList = document.getElementById('cockpit-news-list'); this.localPlace = document.getElementById('cockpit-local-place'); - this.localCoordinates = document.getElementById('cockpit-local-coordinates'); - this.localTemperature = document.getElementById('cockpit-local-temperature'); + this.localCoordinates = document.getElementById( + 'cockpit-local-coordinates', + ); + this.localTemperature = document.getElementById( + 'cockpit-local-temperature', + ); this.localWind = document.getElementById('cockpit-local-wind'); - this.localWindDirection = document.getElementById('cockpit-local-wind-direction'); + this.localWindDirection = document.getElementById( + 'cockpit-local-wind-direction', + ); this.localCondition = document.getElementById('cockpit-local-condition'); this.localCloud = document.getElementById('cockpit-local-cloud'); - this.localPrecipitation = document.getElementById('cockpit-local-precipitation'); + this.localPrecipitation = document.getElementById( + 'cockpit-local-precipitation', + ); this.signalCollapsed = false; this.signalUserCollapsed = false; this.signalItems = []; @@ -166,32 +240,50 @@ export class CockpitViewController { this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1)); this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1)); - this._listen(this.contextPrevious, 'click', () => this.navigateContext(-1, { origin: 'user' })); - this._listen(this.contextNext, 'click', () => this.navigateContext(1, { origin: 'user' })); - this._listen(this.contextToggle, 'click', () => this.setContextCollapsed(!this.contextCollapsed)); + this._listen(this.contextPrevious, 'click', () => + this.navigateContext(-1, { origin: 'user' }), + ); + this._listen(this.contextNext, 'click', () => + this.navigateContext(1, { origin: 'user' }), + ); + this._listen(this.contextToggle, 'click', () => + this.setContextCollapsed(!this.contextCollapsed), + ); this._listen(this.weatherToggle, 'click', () => { - const enabled = this.weatherToggle.getAttribute('aria-pressed') !== 'true'; + const enabled = + this.weatherToggle.getAttribute('aria-pressed') !== 'true'; this.syncWeatherToggle(enabled); - window.dispatchEvent(new CustomEvent('gev:cockpit-weather-toggle', { - detail: { enabled }, - })); + window.dispatchEvent( + new CustomEvent('gev:cockpit-weather-toggle', { + detail: { enabled }, + }), + ); }); this._listen(window, 'gev:cockpit-weather-state', (event) => { this.syncWeatherToggle(event?.detail?.enabled !== false); }); - this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed( - !this.signalCollapsed, - { user: true }, - )); - this._listen(this.signalList, 'click', (event) => this.handleSignalClick(event)); - this._listen(this.briefPrevious, 'click', () => this.showBriefPage(this.briefPageIndex - 1, { manual: true })); - this._listen(this.briefNext, 'click', () => this.showBriefPage(this.briefPageIndex + 1, { manual: true })); + this._listen(this.signalToggle, 'click', () => + this.setSignalCollapsed(!this.signalCollapsed, { user: true }), + ); + this._listen(this.signalList, 'click', (event) => + this.handleSignalClick(event), + ); + this._listen(this.briefPrevious, 'click', () => + this.showBriefPage(this.briefPageIndex - 1, { manual: true }), + ); + this._listen(this.briefNext, 'click', () => + this.showBriefPage(this.briefPageIndex + 1, { manual: true }), + ); this._listen(this.briefAutoToggle, 'click', () => { this.setBriefAutoRotate(!this.briefAutoRotateEnabled); }); - this.briefTabs.forEach((button) => this._listen(button, 'click', () => { - this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { manual: true }); - })); + this.briefTabs.forEach((button) => + this._listen(button, 'click', () => { + this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { + manual: true, + }); + }), + ); this._listen(document, 'visibilitychange', () => { if (document.hidden) this.stopBriefRotation(); else if (this.briefAutoRotateEnabled) this.startBriefRotation(); @@ -203,45 +295,117 @@ export class CockpitViewController { _listen(target, type, handler, options) { if (!target?.addEventListener) return; target.addEventListener(type, handler, options); - this._listenerRemovers.push(() => target.removeEventListener(type, handler, options)); + this._listenerRemovers.push(() => + target.removeEventListener(type, handler, options), + ); } - readAircraftInfo() { return readAircraftInfo.call(this); } - dispatchCockpitModeChanged(active, info) { return dispatchCockpitModeChanged.call(this, active, info); } - toggleTrackedTr3b() { return toggleTrackedTr3b.call(this); } - syncTr3bToggle(info) { return syncTr3bToggle.call(this, info); } - syncEntry() { return syncEntry.call(this); } - navigateContext(direction, options) { return navigateContext.call(this, direction, options); } - _adoptTrackedEntity(nowMs, suppliedInfo) { return _adoptTrackedEntity.call(this, nowMs, suppliedInfo); } - enter() { return enter.call(this); } - exit(options) { return exit.call(this, options); } - update() { return update.call(this); } - updateHud(info, nowMs, forceContext) { return updateHud.call(this, info, nowMs, forceContext); } - updateRoute(info) { return updateRoute.call(this, info); } - syncWeatherToggle(enabled) { return syncWeatherToggle.call(this, enabled); } - setVisionMode(mode, options) { return setVisionMode.call(this, mode, options); } - cycleVisionMode(direction) { return cycleVisionMode.call(this, direction); } - clearPredictiveRoute() { return clearPredictiveRoute.call(this); } - updateContext(info, heading) { return updateContext.call(this, info, heading); } - showBriefPage(index, options) { return showBriefPage.call(this, index, options); } - setBriefAutoRotate(enabled) { return setBriefAutoRotate.call(this, enabled); } - startBriefRotation(options) { return startBriefRotation.call(this, options); } - stopBriefRotation() { return stopBriefRotation.call(this); } - updateLocalPosition(info) { return updateLocalPosition.call(this, info); } - maybeRefreshRegionalBrief(info) { return maybeRefreshRegionalBrief.call(this, info); } - renderRegionalBriefStatus(status, info) { return renderRegionalBriefStatus.call(this, status, info); } - renderRegionalBrief(payload, info) { return renderRegionalBrief.call(this, payload, info); } - renderCockpitSignals() { return renderCockpitSignals.call(this); } - pushCockpitSignal(key, tone, title, detail, target) { return pushCockpitSignal.call(this, key, tone, title, detail, target); } - updateCockpitSignals(snapshot, unknownCount) { return updateCockpitSignals.call(this, snapshot, unknownCount); } - scheduleContextLayout() { return scheduleContextLayout.call(this); } - setContextCollapsed(collapsed) { return setContextCollapsed.call(this, collapsed); } - setSignalCollapsed(collapsed, options) { return setSignalCollapsed.call(this, collapsed, options); } - syncContextLayout() { return syncContextLayout.call(this); } - syncSignalLayout() { return syncSignalLayout.call(this); } - onKeyDown(event) { return onKeyDown.call(this, event); } + readAircraftInfo() { + return readAircraftInfo.call(this); + } + dispatchCockpitModeChanged(active, info) { + return dispatchCockpitModeChanged.call(this, active, info); + } + toggleTrackedTr3b() { + return toggleTrackedTr3b.call(this); + } + syncTr3bToggle(info) { + return syncTr3bToggle.call(this, info); + } + syncEntry() { + return syncEntry.call(this); + } + navigateContext(direction, options) { + return navigateContext.call(this, direction, options); + } + _adoptTrackedEntity(nowMs, suppliedInfo) { + return _adoptTrackedEntity.call(this, nowMs, suppliedInfo); + } + enter() { + return enter.call(this); + } + exit(options) { + return exit.call(this, options); + } + update() { + return update.call(this); + } + updateHud(info, nowMs, forceContext) { + return updateHud.call(this, info, nowMs, forceContext); + } + updateRoute(info) { + return updateRoute.call(this, info); + } + syncWeatherToggle(enabled) { + return syncWeatherToggle.call(this, enabled); + } + setVisionMode(mode, options) { + return setVisionMode.call(this, mode, options); + } + cycleVisionMode(direction) { + return cycleVisionMode.call(this, direction); + } + clearPredictiveRoute() { + return clearPredictiveRoute.call(this); + } + updateContext(info, heading) { + return updateContext.call(this, info, heading); + } + showBriefPage(index, options) { + return showBriefPage.call(this, index, options); + } + setBriefAutoRotate(enabled) { + return setBriefAutoRotate.call(this, enabled); + } + startBriefRotation(options) { + return startBriefRotation.call(this, options); + } + stopBriefRotation() { + return stopBriefRotation.call(this); + } + updateLocalPosition(info) { + return updateLocalPosition.call(this, info); + } + maybeRefreshRegionalBrief(info) { + return maybeRefreshRegionalBrief.call(this, info); + } + renderRegionalBriefStatus(status, info) { + return renderRegionalBriefStatus.call(this, status, info); + } + renderRegionalBrief(payload, info) { + return renderRegionalBrief.call(this, payload, info); + } + renderCockpitSignals() { + return renderCockpitSignals.call(this); + } + pushCockpitSignal(key, tone, title, detail, target) { + return pushCockpitSignal.call(this, key, tone, title, detail, target); + } + updateCockpitSignals(snapshot, unknownCount) { + return updateCockpitSignals.call(this, snapshot, unknownCount); + } + scheduleContextLayout() { + return scheduleContextLayout.call(this); + } + setContextCollapsed(collapsed) { + return setContextCollapsed.call(this, collapsed); + } + setSignalCollapsed(collapsed, options) { + return setSignalCollapsed.call(this, collapsed, options); + } + syncContextLayout() { + return syncContextLayout.call(this); + } + syncSignalLayout() { + return syncSignalLayout.call(this); + } + onKeyDown(event) { + return onKeyDown.call(this, event); + } - handleSignalClick(event) { return handleSignalClick.call(this, event); } + handleSignalClick(event) { + return handleSignalClick.call(this, event); + } dispose() { if (this.destroyed) return; @@ -251,9 +415,10 @@ export class CockpitViewController { this.regionalBriefAbort = null; this.regionalBriefRequestToken += 1; this.stopBriefRotation(); - if (this.contextLayoutFrame !== null) cancelAnimationFrame(this.contextLayoutFrame); + if (this.contextLayoutFrame !== null) + cancelAnimationFrame(this.contextLayoutFrame); this.contextLayoutFrame = null; - for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.(); + for (const removeListener of this._listenerRemovers.splice(0)) + removeListener?.(); } } - diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index 31fdb5a4f..4f8018cbf 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -13,13 +13,27 @@ function environment() { globalThis.document = document; globalThis.window = window; const callbacks = new Set(); - const event = { addEventListener(fn) { callbacks.add(fn); return () => callbacks.delete(fn); } }; + const event = { + addEventListener(fn) { + callbacks.add(fn); + return () => callbacks.delete(fn); + }, + }; const viewer = { - scene: { preUpdate: event, screenSpaceCameraController: { enableInputs: false } }, + scene: { + preUpdate: event, + screenSpaceCameraController: { enableInputs: false }, + }, trackedEntityChanged: event, entities: { contains: () => false }, }; - return { viewer, callbacks, restore() { Object.assign(globalThis, original); } }; + return { + viewer, + callbacks, + restore() { + Object.assign(globalThis, original); + }, + }; } test('Cockpit subscriptions are released once and retained actions cannot restart it', () => { @@ -35,7 +49,9 @@ test('Cockpit subscriptions are released once and retained actions cannot restar assert.equal(owner.toggleTrackedTr3b(), false); assert.equal(owner.update(), false); assert.equal(owner.active, false); - } finally { env.restore(); } + } finally { + env.restore(); + } }); test('disposing an active Cockpit releases the supplied render owner without retracking', () => { @@ -43,33 +59,50 @@ test('disposing an active Cockpit releases the supplied render owner without ret try { const released = []; const owner = new CockpitViewController(env.viewer, { - services: { releaseContinuousRender: reason => released.push(reason) }, + services: { releaseContinuousRender: (reason) => released.push(reason) }, }); owner.active = true; owner.dispose(); owner.dispose(); assert.deepEqual(released, ['cockpit']); - assert.equal(env.viewer.scene.screenSpaceCameraController.enableInputs, true); + assert.equal( + env.viewer.scene.screenSpaceCameraController.enableInputs, + true, + ); assert.equal(env.viewer.trackedEntity, undefined); assert.equal(owner.active, false); - } finally { env.restore(); } + } finally { + env.restore(); + } }); test('aircraft identity is read from the supplied layer instances', () => { const env = environment(); try { - const civilian = { icao24: 'abc123', layerId: 'flights', callsign: 'CIVIL' }; - const military = { icao24: 'def456', layerId: 'military', callsign: 'MILITARY' }; - const owner = new CockpitViewController(env.viewer, { services: { - flightsLayer: { getTrackedInfo: () => civilian }, - militaryFlightsLayer: { getTrackedInfo: () => military }, - } }); + const civilian = { + icao24: 'abc123', + layerId: 'flights', + callsign: 'CIVIL', + }; + const military = { + icao24: 'def456', + layerId: 'military', + callsign: 'MILITARY', + }; + const owner = new CockpitViewController(env.viewer, { + services: { + flightsLayer: { getTrackedInfo: () => civilian }, + militaryFlightsLayer: { getTrackedInfo: () => military }, + }, + }); env.viewer.trackedEntity = { gevTrackedId: 'military:def456' }; assert.deepEqual(owner.readAircraftInfo(), military); env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' }; assert.deepEqual(owner.readAircraftInfo(), civilian); owner.dispose(); - } finally { env.restore(); } + } finally { + env.restore(); + } }); test('an obsolete regional briefing cannot publish after Cockpit disposal', async () => { @@ -77,23 +110,34 @@ test('an obsolete regional briefing cannot publish after Cockpit disposal', asyn try { let finish; let signal; - const owner = new CockpitViewController(env.viewer, { services: { - regionalDistanceM: () => Infinity, - fetchRegionalBrief: (_lat, _lon, options) => { - signal = options.signal; - return new Promise(resolve => { finish = resolve; }); + const owner = new CockpitViewController(env.viewer, { + services: { + regionalDistanceM: () => Infinity, + fetchRegionalBrief: (_lat, _lon, options) => { + signal = options.signal; + return new Promise((resolve) => { + finish = resolve; + }); + }, + releaseContinuousRender() {}, }, - releaseContinuousRender() {}, - } }); + }); const published = []; - owner.renderRegionalBrief = payload => published.push(payload); + owner.renderRegionalBrief = (payload) => published.push(payload); owner.active = true; - owner.maybeRefreshRegionalBrief({ layerId: 'flights', icao24: 'abc123', latitude: 30, longitude: -97 }); + owner.maybeRefreshRegionalBrief({ + layerId: 'flights', + icao24: 'abc123', + latitude: 30, + longitude: -97, + }); owner.dispose(); assert.equal(signal.aborted, true); finish({ articles: [] }); - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(published, []); assert.equal(owner.regionalBriefAbort, null); - } finally { env.restore(); } + } finally { + env.restore(); + } }); diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js index 4da1dc13f..50b13d8b6 100644 --- a/src/ui/cockpitDisplayPortal.js +++ b/src/ui/cockpitDisplayPortal.js @@ -11,7 +11,9 @@ export class CockpitDisplayPortal { this.frames = new Set(); this.listeners = new AbortController(); this.records = groups.flatMap(([name, group]) => { - const slot = cockpitPanel?.querySelector(`[data-cockpit-display-slot="${name}"]`); + const slot = cockpitPanel?.querySelector( + `[data-cockpit-display-slot="${name}"]`, + ); if (!group || !slot || !group.parentNode) return []; const anchor = document.createComment(`cockpit-display-home:${name}`); group.before(anchor); @@ -20,15 +22,27 @@ export class CockpitDisplayPortal { this.standardScrollTop = standardPanel?.scrollTop || 0; this.cockpitScrollTop = cockpitPanel?.scrollTop || 0; const options = { passive: true, signal: this.listeners.signal }; - standardPanel?.addEventListener('scroll', () => { - if (!this.active) this.standardScrollTop = standardPanel.scrollTop; - }, options); - cockpitPanel?.addEventListener('scroll', () => { - if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop; - }, options); - window.addEventListener('gev:cockpit-mode-changed', event => { - this.setActive(event?.detail?.active === true); - }, { signal: this.listeners.signal }); + standardPanel?.addEventListener( + 'scroll', + () => { + if (!this.active) this.standardScrollTop = standardPanel.scrollTop; + }, + options, + ); + cockpitPanel?.addEventListener( + 'scroll', + () => { + if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop; + }, + options, + ); + window.addEventListener( + 'gev:cockpit-mode-changed', + (event) => { + this.setActive(event?.detail?.active === true); + }, + { signal: this.listeners.signal }, + ); this.setActive(document.body.classList.contains('cockpit-mode')); } @@ -52,7 +66,9 @@ export class CockpitDisplayPortal { if (this.active === nextActive) return; this._cancelFrames(); const generation = this.generation; - const focusedRecord = this.records.find(record => record.group.contains(document.activeElement)); + const focusedRecord = this.records.find((record) => + record.group.contains(document.activeElement), + ); const focusedElement = focusedRecord ? document.activeElement : null; this.restoreOwner = nextActive ? 'cockpit' : 'standard'; this.active = nextActive; @@ -60,12 +76,21 @@ export class CockpitDisplayPortal { if (nextActive) record.slot.append(record.group); else if (record.anchor.parentNode) record.anchor.after(record.group); } - this.cockpitPanel?.classList.toggle('uses-shared-display-controls', nextActive); + this.cockpitPanel?.classList.toggle( + 'uses-shared-display-controls', + nextActive, + ); const restoreScroll = () => { - if (nextActive && this.cockpitPanel) this.cockpitPanel.scrollTop = this.cockpitScrollTop; - if (!nextActive && this.standardPanel) this.standardPanel.scrollTop = this.standardScrollTop; + if (nextActive && this.cockpitPanel) + this.cockpitPanel.scrollTop = this.cockpitScrollTop; + if (!nextActive && this.standardPanel) + this.standardPanel.scrollTop = this.standardScrollTop; }; - if (!settle) { restoreScroll(); this.restoreOwner = null; return; } + if (!settle) { + restoreScroll(); + this.restoreOwner = null; + return; + } this._frame(generation, () => { restoreScroll(); focusedElement?.focus?.({ preventScroll: true }); diff --git a/src/ui/cockpitDisplayPortal.test.mjs b/src/ui/cockpitDisplayPortal.test.mjs index aed7d0c74..219508ced 100644 --- a/src/ui/cockpitDisplayPortal.test.mjs +++ b/src/ui/cockpitDisplayPortal.test.mjs @@ -3,49 +3,115 @@ import assert from 'node:assert/strict'; import { CockpitDisplayPortal } from './cockpitDisplayPortal.js'; function fixture() { - const prior = Object.fromEntries(['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map(key => [key, globalThis[key]])); + const prior = Object.fromEntries( + ['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map( + (key) => [key, globalThis[key]], + ), + ); const frames = new Map(); let nextFrame = 0; let focusCalls = 0; let layouts = 0; class Node extends EventTarget { constructor(name) { - super(); this.name = name; this.children = []; this.parentNode = null; this.scrollTop = 0; + super(); + this.name = name; + this.children = []; + this.parentNode = null; + this.scrollTop = 0; this.classList = { toggle() {} }; } append(node) { - node.remove(); this.children.push(node); node.parentNode = this; + node.remove(); + this.children.push(node); + node.parentNode = this; } remove() { - if (this.parentNode) this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + if (this.parentNode) + this.parentNode.children.splice( + this.parentNode.children.indexOf(this), + 1, + ); this.parentNode = null; } before(node) { - node.remove(); const parent = this.parentNode; - parent.children.splice(parent.children.indexOf(this), 0, node); node.parentNode = parent; + node.remove(); + const parent = this.parentNode; + parent.children.splice(parent.children.indexOf(this), 0, node); + node.parentNode = parent; } after(node) { - node.remove(); const parent = this.parentNode; - parent.children.splice(parent.children.indexOf(this) + 1, 0, node); node.parentNode = parent; + node.remove(); + const parent = this.parentNode; + parent.children.splice(parent.children.indexOf(this) + 1, 0, node); + node.parentNode = parent; + } + contains(node) { + return ( + this === node || this.children.some((child) => child.contains(node)) + ); + } + focus() { + focusCalls += 1; + document.activeElement = this; } - contains(node) { return this === node || this.children.some(child => child.contains(node)); } - focus() { focusCalls += 1; document.activeElement = this; } } - const standard = new Node('standard'); standard.scrollTop = 71; - const cockpit = new Node('cockpit'); cockpit.scrollTop = 19; - const group = new Node('group'); const sibling = new Node('sibling'); + const standard = new Node('standard'); + standard.scrollTop = 71; + const cockpit = new Node('cockpit'); + cockpit.scrollTop = 19; + const group = new Node('group'); + const sibling = new Node('sibling'); const slot = new Node('slot'); - standard.append(group); standard.append(sibling); cockpit.append(slot); + standard.append(group); + standard.append(sibling); + cockpit.append(slot); cockpit.querySelector = () => slot; - globalThis.document = { createComment: name => new Node(name), activeElement: group, body: { classList: { contains: () => false } } }; + globalThis.document = { + createComment: (name) => new Node(name), + activeElement: group, + body: { classList: { contains: () => false } }, + }; globalThis.window = new EventTarget(); - globalThis.requestAnimationFrame = fn => { const id = ++nextFrame; frames.set(id, fn); return id; }; - globalThis.cancelAnimationFrame = id => frames.delete(id); - const create = () => new CockpitDisplayPortal({ standardPanel: standard, cockpitPanel: cockpit, groups: [['hud', group]], layout: () => { layouts += 1; } }); - return { standard, cockpit, group, sibling, slot, frames, create, - get focusCalls() { return focusCalls; }, get layouts() { return layouts; }, - flush() { while (frames.size) { const batch = [...frames.values()]; frames.clear(); batch.forEach(fn => fn()); } }, - restore() { Object.assign(globalThis, prior); }, + globalThis.requestAnimationFrame = (fn) => { + const id = ++nextFrame; + frames.set(id, fn); + return id; + }; + globalThis.cancelAnimationFrame = (id) => frames.delete(id); + const create = () => + new CockpitDisplayPortal({ + standardPanel: standard, + cockpitPanel: cockpit, + groups: [['hud', group]], + layout: () => { + layouts += 1; + }, + }); + return { + standard, + cockpit, + group, + sibling, + slot, + frames, + create, + get focusCalls() { + return focusCalls; + }, + get layouts() { + return layouts; + }, + flush() { + while (frames.size) { + const batch = [...frames.values()]; + frames.clear(); + batch.forEach((fn) => fn()); + } + }, + restore() { + Object.assign(globalThis, prior); + }, }; } @@ -66,7 +132,9 @@ test('Display groups return to their exact home with independent scroll position owner.destroy(); assert.deepEqual(f.standard.children, [f.group, f.sibling]); assert.equal(f.frames.size, 0); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('superseded portal frames cannot restore obsolete focus or scroll', () => { @@ -77,30 +145,39 @@ test('superseded portal frames cannot restore obsolete focus or scroll', () => { const obsolete = [...f.frames.values()]; owner.setActive(false); f.cockpit.scrollTop = 93; - obsolete.forEach(callback => callback()); + obsolete.forEach((callback) => callback()); assert.equal(f.focusCalls, 0); assert.equal(f.cockpit.scrollTop, 93); f.flush(); assert.equal(f.focusCalls, 1); assert.equal(owner.restoreOwner, null); owner.destroy(); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('disposal restores groups and revokes mode listeners and queued focus work', () => { const f = fixture(); try { const owner = f.create(); - window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } })); + window.dispatchEvent( + new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }), + ); assert.equal(f.group.parentNode, f.slot); const obsolete = [...f.frames.values()]; - owner.destroy(); owner.destroy(); - obsolete.forEach(callback => callback()); - window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } })); + owner.destroy(); + owner.destroy(); + obsolete.forEach((callback) => callback()); + window.dispatchEvent( + new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }), + ); owner.setActive(true); assert.deepEqual(f.standard.children, [f.group, f.sibling]); assert.equal(f.frames.size, 0); assert.equal(f.focusCalls, 0); assert.equal(f.layouts, 1); - } finally { f.restore(); } + } finally { + f.restore(); + } }); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 21c55a2b8..aed87f483 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -1,54 +1,70 @@ /** Route Cockpit keys while preserving the active disclosure and form-control owner. */ - export function onKeyDown(event) { - if (this.destroyed) return false; - if (event.repeat || event.isComposing) return; - if (event.key === 'Escape' && this.active) { - // The credit lightbox owns Escape while its Close control or links hold - // focus. Its target handler closes the overlay and restores attribution - // focus; Cockpit must stay active behind it. - if (event.target?.closest?.('.cesium-credit-lightbox')) return; - if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return; - if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return; - if (this.context?.contains(event.target) && !this.contextCollapsed) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setContextCollapsed(true); - if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) { - this.contextToggle?.blur?.(); - } else { - this.contextToggle?.focus({ preventScroll: true }); - } - return; - } - if (this.signalStream?.contains(event.target) && !this.signalCollapsed) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setSignalCollapsed(true, { user: true }); - if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) { - this.signalToggle?.blur?.(); - } else { - this.signalToggle?.focus({ preventScroll: true }); - } - return; - } + if (this.destroyed) return false; + if (event.repeat || event.isComposing) return; + if (event.key === 'Escape' && this.active) { + // The credit lightbox owns Escape while its Close control or links hold + // focus. Its target handler closes the overlay and restores attribution + // focus; Cockpit must stay active behind it. + if (event.target?.closest?.('.cesium-credit-lightbox')) return; + if ( + document + .getElementById('context-radio-dock') + ?.classList.contains('disclosure-open') + ) + return; + if ( + document.querySelector('#cockpit-utility-controls [aria-expanded="true"]') + ) + return; + if (this.context?.contains(event.target) && !this.contextCollapsed) { event.preventDefault(); event.stopImmediatePropagation(); - this.exit(); + this.setContextCollapsed(true); + if ( + event.target === this.contextToggle || + this.contextToggle?.contains?.(event.target) + ) { + this.contextToggle?.blur?.(); + } else { + this.contextToggle?.focus({ preventScroll: true }); + } return; } - if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; - const key = event.key?.toLowerCase(); - if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { - if (!this.active) { - const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position); - if (!cockpitAttempt) return; - } + if (this.signalStream?.contains(event.target) && !this.signalCollapsed) { event.preventDefault(); event.stopImmediatePropagation(); - if (!this.active && !this.isEntryAllowed()) return; - const changed = this.active ? this.exit() : this.enter(); + this.setSignalCollapsed(true, { user: true }); + if ( + event.target === this.signalToggle || + this.signalToggle?.contains?.(event.target) + ) { + this.signalToggle?.blur?.(); + } else { + this.signalToggle?.focus({ preventScroll: true }); + } return; } + event.preventDefault(); + event.stopImmediatePropagation(); + this.exit(); + return; } + if (event.target?.closest?.('input, textarea, select, [contenteditable]')) + return; + const key = event.key?.toLowerCase(); + if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { + if (!this.active) { + const cockpitAttempt = !!( + this.readAircraftInfo() && this.viewer.trackedEntity?.position + ); + if (!cockpitAttempt) return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + if (!this.active && !this.isEntryAllowed()) return; + const changed = this.active ? this.exit() : this.enter(); + return; + } +} diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index 07ac2e637..5cb0a558a 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -1,182 +1,260 @@ /** Instrument values, routes and vision controls for the Cockpit controller. */ -import { COCKPIT_VISION_MODES, normalizeCockpitVisionMode } from '../cockpitVisionPolicy.js'; -import { altitudeRulerCurveInset, altitudeRulerTicks, bearingBetweenCoordinates, cockpitAltitudeDisplayFt, cockpitUiUpdateDue, compassDivisions, formatAltitudeRulerTick, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, relativeBearing, speedRulerTicks } from '../cockpitMath.js'; -import { COCKPIT_CONTEXT_UPDATE_MS, setCockpitRollingValue } from './cockpitPresentation.js'; +import { + COCKPIT_VISION_MODES, + normalizeCockpitVisionMode, +} from '../cockpitVisionPolicy.js'; +import { + altitudeRulerCurveInset, + altitudeRulerTicks, + bearingBetweenCoordinates, + cockpitAltitudeDisplayFt, + cockpitUiUpdateDue, + compassDivisions, + formatAltitudeRulerTick, + formatCompassDivision, + formatSpeedRulerTick, + normalizeHeading, + relativeBearing, + speedRulerTicks, +} from '../cockpitMath.js'; +import { + COCKPIT_CONTEXT_UPDATE_MS, + setCockpitRollingValue, +} from './cockpitPresentation.js'; -export function updateHud(info, nowMs = performance.now(), forceContext = false) { - this.lastAircraftInfo = info; - const heading = normalizeHeading(this.heading ?? info.track ?? 0); - if (this.callsign) { - this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; - } - const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null; +export function updateHud( + info, + nowMs = performance.now(), + forceContext = false, +) { + this.lastAircraftInfo = info; + const heading = normalizeHeading(this.heading ?? info.track ?? 0); + if (this.callsign) { + this.callsign.textContent = + info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; + } + const speedKt = Number.isFinite(info.velocityMps) + ? info.velocityMps * 1.94384 + : null; + setCockpitRollingValue(this.speed, formatSpeedRulerTick(speedKt), speedKt, { + immediate: forceContext, + }); + if (this.speedRim) + this.speedRim.classList.toggle('unavailable', speedKt === null); + if (this.speedRimValue) + this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); + const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); + this.speedRimTicks.forEach((element, index) => { + const tick = speedTicks[index]; + element.hidden = !tick; + if (!tick) return; + element.style.setProperty('--slot', tick.slot.toFixed(4)); + element.style.setProperty('--depth', tick.depth.toFixed(4)); + element.style.setProperty( + '--curve', + altitudeRulerCurveInset(tick.slot).toFixed(5), + ); + element.classList.toggle('major', tick.major); + const label = element.querySelector('b'); + if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); + }); + const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround); + if (this.altitude) { + const displayedAltitudeFt = Number.isFinite(altitudeFt) + ? Math.round(altitudeFt) + : null; setCockpitRollingValue( - this.speed, - formatSpeedRulerTick(speedKt), - speedKt, + this.altitude, + displayedAltitudeFt !== null + ? displayedAltitudeFt.toLocaleString('en-US') + : '-----', + displayedAltitudeFt, { immediate: forceContext }, ); - if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null); - if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); - const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); - this.speedRimTicks.forEach((element, index) => { - const tick = speedTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); - }); - const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround); - if (this.altitude) { - const displayedAltitudeFt = Number.isFinite(altitudeFt) - ? Math.round(altitudeFt) - : null; - setCockpitRollingValue( - this.altitude, - displayedAltitudeFt !== null - ? displayedAltitudeFt.toLocaleString('en-US') - : '-----', - displayedAltitudeFt, - { immediate: forceContext }, - ); - } - if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); - if (this.altitudeRimValue) { - this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); - } - const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length); - this.altitudeRimTicks.forEach((element, index) => { - const tick = altitudeTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); - }); - setCockpitRollingValue( - this.headingValue, - String(Math.round(heading) % 360).padStart(3, '0'), - heading, - { circularRange: 360, immediate: forceContext }, - ); - if (this.compassTape) { - const divisions = compassDivisions(heading); - const signature = divisions.join(','); - if (signature !== this.lastCompassSignature) { - this.lastCompassSignature = signature; - this.compassTape.innerHTML = divisions - .map((division, index) => { - const slot = index - 3; - return `${formatCompassDivision(division)}`; - }) - .join(''); - } - } - if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; - if (this.position) { - const lat = Number.isFinite(info.latitude) - ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--'; - const lon = Number.isFinite(info.longitude) - ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--'; - this.position.textContent = `${lat} · ${lon}`; - } - if (this.aircraftMeta) { - const feedState = this.surfaceAcquiring - ? 'ACQUIRING SURFACE' - : (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK')); - this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; - } - this.updateRoute(info); - if (forceContext - || cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) { - this.lastContextUpdateMs = nowMs; - this.updateLocalPosition(info); - this.maybeRefreshRegionalBrief(info); - this.updateContext(info, heading); - } - if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; } + if (this.altitudeRim) + this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); + if (this.altitudeRimValue) { + this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); + } + const altitudeTicks = altitudeRulerTicks( + altitudeFt, + this.altitudeRimTicks.length, + ); + this.altitudeRimTicks.forEach((element, index) => { + const tick = altitudeTicks[index]; + element.hidden = !tick; + if (!tick) return; + element.style.setProperty('--slot', tick.slot.toFixed(4)); + element.style.setProperty('--depth', tick.depth.toFixed(4)); + element.style.setProperty( + '--curve', + altitudeRulerCurveInset(tick.slot).toFixed(5), + ); + element.classList.toggle('major', tick.major); + const label = element.querySelector('b'); + if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); + }); + setCockpitRollingValue( + this.headingValue, + String(Math.round(heading) % 360).padStart(3, '0'), + heading, + { circularRange: 360, immediate: forceContext }, + ); + if (this.compassTape) { + const divisions = compassDivisions(heading); + const signature = divisions.join(','); + if (signature !== this.lastCompassSignature) { + this.lastCompassSignature = signature; + this.compassTape.innerHTML = divisions + .map((division, index) => { + const slot = index - 3; + return `${formatCompassDivision(division)}`; + }) + .join(''); + } + } + if (this.clock) + this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; + if (this.position) { + const lat = Number.isFinite(info.latitude) + ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` + : '--'; + const lon = Number.isFinite(info.longitude) + ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` + : '--'; + this.position.textContent = `${lat} · ${lon}`; + } + if (this.aircraftMeta) { + const feedState = this.surfaceAcquiring + ? 'ACQUIRING SURFACE' + : this.surfaceFallback + ? 'SURFACE FALLBACK' + : info.stale + ? 'STALE FEED' + : 'LIVE TRACK'; + this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; + } + this.updateRoute(info); + if ( + forceContext || + cockpitUiUpdateDue( + nowMs, + this.lastContextUpdateMs, + COCKPIT_CONTEXT_UPDATE_MS, + ) + ) { + this.lastContextUpdateMs = nowMs; + this.updateLocalPosition(info); + this.maybeRefreshRegionalBrief(info); + this.updateContext(info, heading); + } + if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; +} export function updateRoute(info) { - const origin = info?.route?.origin; - const destination = info?.route?.destination; - const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); - const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN'; - if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); - if (this.routeTo) this.routeTo.textContent = routeLabel(destination); - if (this.routeStatus) { - this.routeStatus.textContent = validDestination - ? 'ARROW · ESTIMATED DIRECTION' - : 'ROUTE DATA UNAVAILABLE'; - } - if (this.route) this.route.hidden = !origin && !destination; - if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) { - this.clearPredictiveRoute(); - return; - } - const destinationBearing = bearingBetweenCoordinates( - info.latitude, - info.longitude, - destination.lat, - destination.lon, - ); - const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0); - if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) { - this.clearPredictiveRoute(); - return; - } - if (this.routeDirection) { - const displayedRelative = Math.max(-120, Math.min(120, relative)); - this.routeDirection.hidden = false; - this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`); - } - if (this.routeDirectionLabel) { - this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`; - } + const origin = info?.route?.origin; + const destination = info?.route?.destination; + const validDestination = + Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); + const routeLabel = (airport) => + [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN'; + if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); + if (this.routeTo) this.routeTo.textContent = routeLabel(destination); + if (this.routeStatus) { + this.routeStatus.textContent = validDestination + ? 'ARROW · ESTIMATED DIRECTION' + : 'ROUTE DATA UNAVAILABLE'; } + if (this.route) this.route.hidden = !origin && !destination; + if ( + !validDestination || + !Number.isFinite(info?.longitude) || + !Number.isFinite(info?.latitude) + ) { + this.clearPredictiveRoute(); + return; + } + const destinationBearing = bearingBetweenCoordinates( + info.latitude, + info.longitude, + destination.lat, + destination.lon, + ); + const relative = relativeBearing( + destinationBearing, + this.heading ?? info.track ?? 0, + ); + if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) { + this.clearPredictiveRoute(); + return; + } + if (this.routeDirection) { + const displayedRelative = Math.max(-120, Math.min(120, relative)); + this.routeDirection.hidden = false; + this.routeDirection.style.setProperty( + '--route-angle', + `${displayedRelative.toFixed(2)}deg`, + ); + } + if (this.routeDirectionLabel) { + this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`; + } +} export function syncWeatherToggle(enabled) { - if (!this.weatherToggle) return; - const active = !!enabled; - this.weatherToggle.setAttribute('aria-pressed', String(active)); - this.weatherToggle.setAttribute( - 'aria-label', - `${active ? 'Disable' : 'Enable'} cockpit weather effects`, - ); - this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`; - if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF'; - } + if (!this.weatherToggle) return; + const active = !!enabled; + this.weatherToggle.setAttribute('aria-pressed', String(active)); + this.weatherToggle.setAttribute( + 'aria-label', + `${active ? 'Disable' : 'Enable'} cockpit weather effects`, + ); + this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`; + if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF'; +} export function setVisionMode(mode, { revealParameters = false } = {}) { - const next = normalizeCockpitVisionMode(mode); - this.visionMode = next; - const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase(); - const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' }; - const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' }; - if (this.visionCurrent) { - this.visionCurrent.dataset.cockpitVision = next; - this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`); - this.visionCurrent.title = `Current style: ${names[next]} — click for next`; - } - if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next]; - this.onVisionChange?.(next, this.active, { revealParameters }); + const next = normalizeCockpitVisionMode(mode); + this.visionMode = next; + const inherited = String( + this.getInheritedVisionLabel?.() || 'NORMAL', + ).toUpperCase(); + const labels = { + optical: inherited, + crt: 'CRT', + nvg: 'NVG', + thermal: 'FLIR', + noir: 'NOIR', + }; + const names = { + optical: inherited, + crt: 'CRT', + nvg: 'Night vision', + thermal: 'Thermal', + noir: 'Noir', + }; + if (this.visionCurrent) { + this.visionCurrent.dataset.cockpitVision = next; + this.visionCurrent.setAttribute( + 'aria-label', + `Current cockpit vision style: ${names[next]}. Activate for next style.`, + ); + this.visionCurrent.title = `Current style: ${names[next]} — click for next`; } + if (this.visionCurrentLabel) + this.visionCurrentLabel.textContent = labels[next]; + this.onVisionChange?.(next, this.active, { revealParameters }); +} export function cycleVisionMode(direction = 1) { - const modes = COCKPIT_VISION_MODES; - const currentIndex = Math.max(0, modes.indexOf(this.visionMode)); - const step = direction < 0 ? -1 : 1; - const nextIndex = (currentIndex + step + modes.length) % modes.length; - this.setVisionMode(modes[nextIndex], { revealParameters: true }); - } + const modes = COCKPIT_VISION_MODES; + const currentIndex = Math.max(0, modes.indexOf(this.visionMode)); + const step = direction < 0 ? -1 : 1; + const nextIndex = (currentIndex + step + modes.length) % modes.length; + this.setVisionMode(modes[nextIndex], { revealParameters: true }); +} export function clearPredictiveRoute() { - if (this.routeDirection) this.routeDirection.hidden = true; - } + if (this.routeDirection) this.routeDirection.hidden = true; +} diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js index 949f60a7f..058c69c99 100644 --- a/src/ui/cockpitLayout.js +++ b/src/ui/cockpitLayout.js @@ -1,152 +1,212 @@ /** Measure Cockpit panels against visible HUD and viewport obstacles. */ -import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout } from '../cockpitUtilityLayout.js'; -import { COCKPIT_UTILITY_REC_GAP_PX, COCKPIT_UTILITY_SIGNAL_GAP_PX, COCKPIT_UTILITY_MIN_TOP_PX, COCKPIT_UTILITY_MIN_TOP_RATIO, COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, isRenderedOnScreen } from './cockpitPresentation.js'; +import { + resolveCockpitUtilityAnchor, + resolveCockpitUtilityLayout, +} from '../cockpitUtilityLayout.js'; +import { + COCKPIT_UTILITY_REC_GAP_PX, + COCKPIT_UTILITY_SIGNAL_GAP_PX, + COCKPIT_UTILITY_MIN_TOP_PX, + COCKPIT_UTILITY_MIN_TOP_RATIO, + COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, + isRenderedOnScreen, +} from './cockpitPresentation.js'; export function scheduleContextLayout() { - if (this.destroyed) return false; - const contextVisible = this.context && !this.context.hidden; - const signalVisible = this.signalStream && !this.signalStream.hidden; - if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return; - this.contextLayoutFrame = requestAnimationFrame(() => { - this.contextLayoutFrame = null; - this.syncContextLayout(); - this.syncSignalLayout(); - }); - } + if (this.destroyed) return false; + const contextVisible = this.context && !this.context.hidden; + const signalVisible = this.signalStream && !this.signalStream.hidden; + if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) + return; + this.contextLayoutFrame = requestAnimationFrame(() => { + this.contextLayoutFrame = null; + this.syncContextLayout(); + this.syncSignalLayout(); + }); +} export function setContextCollapsed(collapsed) { - const wasCollapsed = this.contextCollapsed; - this.contextCollapsed = Boolean(collapsed); - if (this.context) this.context.dataset.collapsed = String(this.contextCollapsed); - if (this.contextToggle) { - const expanded = !this.contextCollapsed; - this.contextToggle.setAttribute('aria-expanded', String(expanded)); - this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`); - this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`; - const icon = this.contextToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; - } - if (this.active && wasCollapsed && !this.contextCollapsed) { - window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); - } - this.scheduleContextLayout(); + const wasCollapsed = this.contextCollapsed; + this.contextCollapsed = Boolean(collapsed); + if (this.context) + this.context.dataset.collapsed = String(this.contextCollapsed); + if (this.contextToggle) { + const expanded = !this.contextCollapsed; + this.contextToggle.setAttribute('aria-expanded', String(expanded)); + this.contextToggle.setAttribute( + 'aria-label', + `${expanded ? 'Collapse' : 'Expand'} Contact panel`, + ); + this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`; + const icon = this.contextToggle.querySelector('.material-symbols-outlined'); + if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; } + if (this.active && wasCollapsed && !this.contextCollapsed) { + window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); + } + this.scheduleContextLayout(); +} export function setSignalCollapsed(collapsed, { user = false } = {}) { - const wasCollapsed = this.signalCollapsed; - this.signalCollapsed = Boolean(collapsed); - if (user) this.signalUserCollapsed = this.signalCollapsed; - if (this.signalStream) this.signalStream.dataset.collapsed = String(this.signalCollapsed); - if (this.signalToggle) { - const expanded = !this.signalCollapsed; - this.signalToggle.setAttribute('aria-expanded', String(expanded)); - this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`); - this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`; - const icon = this.signalToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; - } - if (this.signalCollapsed) this.stopBriefRotation(); - else this.startBriefRotation({ reset: true }); - if (this.active && wasCollapsed && !this.signalCollapsed) { - window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded')); - } - this.scheduleContextLayout(); + const wasCollapsed = this.signalCollapsed; + this.signalCollapsed = Boolean(collapsed); + if (user) this.signalUserCollapsed = this.signalCollapsed; + if (this.signalStream) + this.signalStream.dataset.collapsed = String(this.signalCollapsed); + if (this.signalToggle) { + const expanded = !this.signalCollapsed; + this.signalToggle.setAttribute('aria-expanded', String(expanded)); + this.signalToggle.setAttribute( + 'aria-label', + `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`, + ); + this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`; + const icon = this.signalToggle.querySelector('.material-symbols-outlined'); + if (icon) + icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; } + if (this.signalCollapsed) this.stopBriefRotation(); + else this.startBriefRotation({ reset: true }); + if (this.active && wasCollapsed && !this.signalCollapsed) { + window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded')); + } + this.scheduleContextLayout(); +} export function syncContextLayout() { - if (!this.context || this.context.hidden) return; - if (window.matchMedia('(max-width: 760px)').matches) { - this.context.dataset.layoutMode = 'compact-bottom'; - this.context.style.removeProperty('--cockpit-context-left'); - this.context.style.removeProperty('--cockpit-context-top'); - this.context.style.removeProperty('--cockpit-context-max-height'); - return; - } - - const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); - this.context.dataset.layoutMode = 'bottom-left'; - this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`); + if (!this.context || this.context.hidden) return; + if (window.matchMedia('(max-width: 760px)').matches) { + this.context.dataset.layoutMode = 'compact-bottom'; + this.context.style.removeProperty('--cockpit-context-left'); this.context.style.removeProperty('--cockpit-context-top'); this.context.style.removeProperty('--cockpit-context-max-height'); + return; } + const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); + this.context.dataset.layoutMode = 'bottom-left'; + this.context.style.setProperty( + '--cockpit-context-left', + `${desktopInset.toFixed(1)}px`, + ); + this.context.style.removeProperty('--cockpit-context-top'); + this.context.style.removeProperty('--cockpit-context-max-height'); +} + export function syncSignalLayout() { - if (!this.signalStream || this.signalStream.hidden) return; - const utilityControls = document.getElementById('cockpit-utility-controls'); - if (window.matchMedia('(max-width: 760px)').matches) { - this.signalStream.dataset.layoutMode = 'compact-top'; - utilityControls?.classList.remove('layout-primary-only'); - utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { + if (!this.signalStream || this.signalStream.hidden) return; + const utilityControls = document.getElementById('cockpit-utility-controls'); + if (window.matchMedia('(max-width: 760px)').matches) { + this.signalStream.dataset.layoutMode = 'compact-top'; + utilityControls?.classList.remove('layout-primary-only'); + utilityControls + ?.querySelectorAll('.cockpit-utility-control') + .forEach((control) => { const hiddenSibling = Boolean( - utilityControls.querySelector('.cockpit-utility-control.is-expanded') - && !control.classList.contains('is-expanded') + utilityControls.querySelector( + '.cockpit-utility-control.is-expanded', + ) && !control.classList.contains('is-expanded'), ); control.setAttribute('aria-hidden', String(hiddenSibling)); }); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height'); - this.signalStream.style.removeProperty('--cockpit-signal-right'); - this.signalStream.style.removeProperty('--cockpit-signal-top'); - this.signalStream.style.removeProperty('--cockpit-signal-max-height'); - return; - } - - const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); - this.signalStream.dataset.layoutMode = 'bottom-right'; - this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`); + this.hud?.style.removeProperty('--cockpit-utility-top'); + this.hud?.style.removeProperty('--cockpit-utility-max-height'); + this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height'); + this.signalStream.style.removeProperty('--cockpit-signal-right'); this.signalStream.style.removeProperty('--cockpit-signal-top'); this.signalStream.style.removeProperty('--cockpit-signal-max-height'); - const signalBounds = this.signalStream.getBoundingClientRect(); - const utilityBounds = utilityControls?.getBoundingClientRect(); - if (utilityBounds) { - const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded'); - const collapsedControl = utilityControls?.querySelector( - '.cockpit-utility-control:not(.is-expanded)', - ); - const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher'); - const expandedHeight = expandedControl - ? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height) - : 0; - const collapsedHeight = collapsedLauncher - ? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight) - : 0; - // Cockpit owns this anchor outright. The strip used to inherit the left - // accordion's committed top, which is solved against left-lane obstacles - // and dropped the strip straight through the briefing card below it. - // The readout only anchors the strip while it is genuinely on screen: - // the Minimal variant drops it with `display:none`, but HUD Off hides the - // whole Intel HUD with `visibility`/`opacity`, which keeps its rect. - const recReadout = document.querySelector('#intel-hud .hud-top-right'); - const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null; - const utilityAnchor = resolveCockpitUtilityAnchor({ - recBottom: recBounds ? recBounds.bottom : 0, - signalTop: signalBounds.top, - stripHeight: utilityBounds.height, - viewportHeight: window.innerHeight, - collapsedHeight, - recGap: COCKPIT_UTILITY_REC_GAP_PX, - signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX, - minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX, - minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, - }); - const availableHeight = utilityAnchor.maxHeight; - this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`); - this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`); - const utilityLayout = expandedControl && collapsedControl - ? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight }) + return; + } + + const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); + this.signalStream.dataset.layoutMode = 'bottom-right'; + this.signalStream.style.setProperty( + '--cockpit-signal-right', + `${desktopInset.toFixed(1)}px`, + ); + this.signalStream.style.removeProperty('--cockpit-signal-top'); + this.signalStream.style.removeProperty('--cockpit-signal-max-height'); + const signalBounds = this.signalStream.getBoundingClientRect(); + const utilityBounds = utilityControls?.getBoundingClientRect(); + if (utilityBounds) { + const expandedControl = utilityControls?.querySelector( + '.cockpit-utility-control.is-expanded', + ); + const collapsedControl = utilityControls?.querySelector( + '.cockpit-utility-control:not(.is-expanded)', + ); + const collapsedLauncher = collapsedControl?.querySelector( + '.cockpit-utility-launcher', + ); + const expandedHeight = expandedControl + ? Math.max( + expandedControl.scrollHeight, + expandedControl.getBoundingClientRect().height, + ) + : 0; + const collapsedHeight = collapsedLauncher + ? Math.max( + COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, + collapsedLauncher.scrollHeight, + ) + : 0; + // Cockpit owns this anchor outright. The strip used to inherit the left + // accordion's committed top, which is solved against left-lane obstacles + // and dropped the strip straight through the briefing card below it. + // The readout only anchors the strip while it is genuinely on screen: + // the Minimal variant drops it with `display:none`, but HUD Off hides the + // whole Intel HUD with `visibility`/`opacity`, which keeps its rect. + const recReadout = document.querySelector('#intel-hud .hud-top-right'); + const recBounds = isRenderedOnScreen(recReadout) + ? recReadout.getBoundingClientRect() + : null; + const utilityAnchor = resolveCockpitUtilityAnchor({ + recBottom: recBounds ? recBounds.bottom : 0, + signalTop: signalBounds.top, + stripHeight: utilityBounds.height, + viewportHeight: window.innerHeight, + collapsedHeight, + recGap: COCKPIT_UTILITY_REC_GAP_PX, + signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX, + minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX, + minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, + }); + const availableHeight = utilityAnchor.maxHeight; + this.hud?.style.setProperty( + '--cockpit-utility-top', + `${utilityAnchor.top.toFixed(1)}px`, + ); + this.hud?.style.setProperty( + '--cockpit-utility-max-height', + `${availableHeight.toFixed(2)}px`, + ); + const utilityLayout = + expandedControl && collapsedControl + ? resolveCockpitUtilityLayout({ + availableHeight, + expandedHeight, + collapsedHeight, + }) : { primaryOnly: false, expandedMaxHeight: availableHeight }; - utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly); - this.hud?.style.setProperty( - '--cockpit-utility-expanded-max-height', - `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, - ); - utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { - const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl; + utilityControls?.classList.toggle( + 'layout-primary-only', + utilityLayout.primaryOnly, + ); + this.hud?.style.setProperty( + '--cockpit-utility-expanded-max-height', + `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, + ); + utilityControls + ?.querySelectorAll('.cockpit-utility-control') + .forEach((control) => { + const hiddenSibling = + utilityLayout.primaryOnly && control === collapsedControl; control.setAttribute('aria-hidden', String(hiddenSibling)); if (hiddenSibling && control.contains(document.activeElement)) { - expandedControl?.querySelector('.cockpit-utility-glyph')?.focus({ preventScroll: true }); + expandedControl + ?.querySelector('.cockpit-utility-glyph') + ?.focus({ preventScroll: true }); } }); - } } +} diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 924e7765e..c77d9cb5b 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -31,9 +31,11 @@ export const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000; export const COCKPIT_BRIEF_ROTATE_MS = 9000; -export const COCKPIT_BRIEF_CYCLE_OFF_HELP = 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.'; +export const COCKPIT_BRIEF_CYCLE_OFF_HELP = + 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.'; -export const COCKPIT_BRIEF_CYCLE_ON_HELP = 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.'; +export const COCKPIT_BRIEF_CYCLE_ON_HELP = + 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.'; export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000; @@ -64,7 +66,11 @@ export function isRenderedOnScreen(element) { if (!element) return false; for (let node = element; node instanceof Element; node = node.parentElement) { const style = getComputedStyle(node); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity) === 0 + ) { return false; } } @@ -88,45 +94,56 @@ export function formatCockpitWindDirection(value) { return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`; } -export function setCockpitRollingValue(element, text, numericValue, { - circularRange = null, - immediate = false, -} = {}) { +export function setCockpitRollingValue( + element, + text, + numericValue, + { circularRange = null, immediate = false } = {}, +) { if (!element) return; const nextText = String(text); const previousText = element.dataset.rollingText; const previousValue = Number(element.dataset.rollingValue); const nowMs = performance.now(); const lastRollMs = Number(element.dataset.rollingAt); - if (!immediate - && previousText !== undefined - && previousText !== nextText - && Number.isFinite(lastRollMs) - && nowMs - lastRollMs < 220) { + if ( + !immediate && + previousText !== undefined && + previousText !== nextText && + Number.isFinite(lastRollMs) && + nowMs - lastRollMs < 220 + ) { return; } element.dataset.rollingText = nextText; element.dataset.rollingAt = String(nowMs); - if (Number.isFinite(numericValue)) element.dataset.rollingValue = String(numericValue); + if (Number.isFinite(numericValue)) + element.dataset.rollingValue = String(numericValue); else delete element.dataset.rollingValue; element.setAttribute('aria-label', nextText); if (immediate || previousText === undefined || previousText === nextText) { - if (previousText !== nextText || !element.querySelector('.cockpit-roll-token')) { - element.replaceChildren(...Array.from(nextText, (character) => { - const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; - token.setAttribute('aria-hidden', 'true'); - token.textContent = character; - return token; - })); + if ( + previousText !== nextText || + !element.querySelector('.cockpit-roll-token') + ) { + element.replaceChildren( + ...Array.from(nextText, (character) => { + const token = document.createElement('span'); + token.className = 'cockpit-roll-token'; + token.setAttribute('aria-hidden', 'true'); + token.textContent = character; + return token; + }), + ); } return; } - let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue) - ? numericValue - previousValue - : 0; + let delta = + Number.isFinite(numericValue) && Number.isFinite(previousValue) + ? numericValue - previousValue + : 0; if (Number.isFinite(circularRange) && circularRange > 0) { const halfRange = circularRange / 2; if (delta > halfRange) delta -= circularRange; @@ -145,9 +162,11 @@ export function setCockpitRollingValue(element, text, numericValue, { token.className = 'cockpit-roll-token'; token.setAttribute('aria-hidden', 'true'); - if (previousCharacter === nextCharacter - || !/\d/.test(previousCharacter) - || !/\d/.test(nextCharacter)) { + if ( + previousCharacter === nextCharacter || + !/\d/.test(previousCharacter) || + !/\d/.test(nextCharacter) + ) { token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter; fragment.append(token); continue; diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js index 6229a0172..b8d6fe18b 100644 --- a/src/ui/cockpitSignals.js +++ b/src/ui/cockpitSignals.js @@ -1,174 +1,198 @@ /** Reconcile actionable signal rows without disturbing native keyboard focus. */ - export function renderCockpitSignals() { - if (!this.signalList) return; - const existing = [...this.signalList.children]; - const focusedEntry = existing.find((entry) => entry.contains(document.activeElement)); - const entriesByKey = new Map(); - for (const entry of existing) { - const key = entry.dataset.signalKey; - if (!entriesByKey.has(key)) entriesByKey.set(key, []); - entriesByKey.get(key).push(entry); - } - const setText = (element, value) => { - if (element.textContent !== value) element.textContent = value; - }; - const entries = this.signalItems.map((item) => { - // Identity includes the action target: a reused status key must never - // silently turn a focused flight button into a different selection. - const key = JSON.stringify([ - item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''), - ]); - let entry = entriesByKey.get(key)?.shift(); - if (!entry) { - entry = document.createElement('li'); - entry.dataset.signalKey = key; - const time = document.createElement('time'); - const body = document.createElement('div'); - const heading = document.createElement(item.target ? 'button' : 'strong'); - if (item.target) { - heading.type = 'button'; - heading.className = 'cockpit-signal-target'; - const label = document.createElement('span'); - label.className = 'cockpit-signal-target-label'; - const rule = document.createElement('span'); - rule.className = 'cockpit-signal-target-rule'; - rule.setAttribute('aria-hidden', 'true'); - const chevron = document.createElement('span'); - chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; - chevron.setAttribute('aria-hidden', 'true'); - chevron.textContent = 'chevron_right'; - heading.append(label, rule, chevron); - } - body.append(heading, document.createElement('span')); - entry.append(time, body); - } - const [time, body] = entry.children; - const [heading, copy] = body.children; - const className = item.target ? `${item.tone} actionable` : item.tone; - if (entry.className !== className) entry.className = className; - setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'); - if (item.target) { - heading.dataset.signalLayer = item.target.layerId; - heading.dataset.signalId = item.target.id; - const label = `Select flight ${item.title}`; - if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label); - setText(heading.children[0], item.title); - } else { - setText(heading, item.title); - } - setText(copy, item.detail); - return entry; - }); - const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null; - if (focusedEntry && !retainedFocus) { - // A departed contact cannot remain selectable. Continue from the stable - // briefing footer instead of dropping keyboard traversal to the page top. - (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true }); - } - for (const entry of existing) if (!entries.includes(entry)) entry.remove(); - - // Ordinary insertBefore moves disconnect an element and lose its focus. - // Reorder the other rows around the focused row, which stays connected. - const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1; - if (retainedFocus) { - let anchor = retainedFocus; - for (let index = focusIndex - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor); - anchor = entry; - } - } - let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild; - for (let index = focusIndex + 1; index < entries.length; index += 1) { - const entry = entries[index]; - if (entry === anchor) anchor = anchor.nextElementSibling; - else this.signalList.insertBefore(entry, anchor); - } - this.scheduleContextLayout(); + if (!this.signalList) return; + const existing = [...this.signalList.children]; + const focusedEntry = existing.find((entry) => + entry.contains(document.activeElement), + ); + const entriesByKey = new Map(); + for (const entry of existing) { + const key = entry.dataset.signalKey; + if (!entriesByKey.has(key)) entriesByKey.set(key, []); + entriesByKey.get(key).push(entry); } + const setText = (element, value) => { + if (element.textContent !== value) element.textContent = value; + }; + const entries = this.signalItems.map((item) => { + // Identity includes the action target: a reused status key must never + // silently turn a focused flight button into a different selection. + const key = JSON.stringify([ + item.key, + Boolean(item.target), + item.target?.layerId || '', + String(item.target?.id ?? ''), + ]); + let entry = entriesByKey.get(key)?.shift(); + if (!entry) { + entry = document.createElement('li'); + entry.dataset.signalKey = key; + const time = document.createElement('time'); + const body = document.createElement('div'); + const heading = document.createElement(item.target ? 'button' : 'strong'); + if (item.target) { + heading.type = 'button'; + heading.className = 'cockpit-signal-target'; + const label = document.createElement('span'); + label.className = 'cockpit-signal-target-label'; + const rule = document.createElement('span'); + rule.className = 'cockpit-signal-target-rule'; + rule.setAttribute('aria-hidden', 'true'); + const chevron = document.createElement('span'); + chevron.className = + 'material-symbols-outlined cockpit-signal-target-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + chevron.textContent = 'chevron_right'; + heading.append(label, rule, chevron); + } + body.append(heading, document.createElement('span')); + entry.append(time, body); + } + const [time, body] = entry.children; + const [heading, copy] = body.children; + const className = item.target ? `${item.tone} actionable` : item.tone; + if (entry.className !== className) entry.className = className; + setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'); + if (item.target) { + heading.dataset.signalLayer = item.target.layerId; + heading.dataset.signalId = item.target.id; + const label = `Select flight ${item.title}`; + if (heading.getAttribute('aria-label') !== label) + heading.setAttribute('aria-label', label); + setText(heading.children[0], item.title); + } else { + setText(heading, item.title); + } + setText(copy, item.detail); + return entry; + }); + const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null; + if (focusedEntry && !retainedFocus) { + // A departed contact cannot remain selectable. Continue from the stable + // briefing footer instead of dropping keyboard traversal to the page top. + (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ + preventScroll: true, + }); + } + for (const entry of existing) if (!entries.includes(entry)) entry.remove(); + + // Ordinary insertBefore moves disconnect an element and lose its focus. + // Reorder the other rows around the focused row, which stays connected. + const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1; + if (retainedFocus) { + let anchor = retainedFocus; + for (let index = focusIndex - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.nextElementSibling !== anchor) + this.signalList.insertBefore(entry, anchor); + anchor = entry; + } + } + let anchor = retainedFocus + ? retainedFocus.nextElementSibling + : this.signalList.firstElementChild; + for (let index = focusIndex + 1; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === anchor) anchor = anchor.nextElementSibling; + else this.signalList.insertBefore(entry, anchor); + } + this.scheduleContextLayout(); +} export function pushCockpitSignal(key, tone, title, detail, target = null) { - if (!this.signalList || !title || !detail) return; - const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; - if (this.signalSignatures.get(key) === signature) return; - this.signalSignatures.set(key, signature); - this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() }); - this.signalItems = this.signalItems.slice(0, 5); - this.renderCockpitSignals(); - } + if (!this.signalList || !title || !detail) return; + const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; + if (this.signalSignatures.get(key) === signature) return; + this.signalSignatures.set(key, signature); + this.signalItems.unshift({ + key, + tone, + title, + detail, + target, + timestamp: Date.now(), + }); + this.signalItems = this.signalItems.slice(0, 5); + this.renderCockpitSignals(); +} export function updateCockpitSignals(snapshot, unknownCount) { - const previous = new Map(this.signalItems.map((item) => [item.key, item])); - const contacts = []; - const subject = snapshot.subject; - if (['flights', 'military'].includes(subject?.layerId) && subject?.id) { - contacts.push({ - key: `flight:${subject.layerId}:${subject.id}`, - tone: 'track', - title: subject.label || subject.id, - detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`, - target: { layerId: subject.layerId, id: String(subject.id) }, - distanceM: -1, - }); - } - for (const cohort of snapshot.cohorts) { - if (!['flights', 'military'].includes(cohort.id)) continue; - for (const item of cohort.nearest) { - const id = item.icao24 || item.id; - if (!id) continue; - contacts.push({ - key: `flight:${cohort.id}:${id}`, - tone: cohort.id === 'military' ? 'nearby military' : 'nearby', - // `id` above is IDENTITY (the ICAO hex). Display must not reuse it: - // the row's own `id` already carries the layer's label convention - // (callsign → registration → hex), so a callsign-less enriched - // contact reads as its registration here too. Same helper the - // Context panel's nearest list uses. - title: this.services.formatAwarenessLabel(item), - detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${ - Number.isFinite(item.distanceM) - ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` - : 'DISTANCE UNKNOWN' - }`, - target: { layerId: cohort.id, id: String(id) }, - distanceM: item.distanceM ?? Infinity, - }); - } - } - contacts.sort((a, b) => a.distanceM - b.distanceM); - const nextItems = contacts.slice(0, 5).map((item) => ({ - ...item, - timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), - })); - if (unknownCount) { - const sources = snapshot.cohorts - .filter((cohort) => cohort.count === null) - .map((cohort) => cohort.source) - .join(' · '); - nextItems.splice(4, Math.max(0, nextItems.length - 4), { - key: 'input-status', - tone: 'warning', - title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`, - detail: sources || 'SOURCE STATUS UNAVAILABLE', - target: null, - timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(), - }); - } - this.signalItems = nextItems; - this.signalSignatures.clear(); - this.renderCockpitSignals(); + const previous = new Map(this.signalItems.map((item) => [item.key, item])); + const contacts = []; + const subject = snapshot.subject; + if (['flights', 'military'].includes(subject?.layerId) && subject?.id) { + contacts.push({ + key: `flight:${subject.layerId}:${subject.id}`, + tone: 'track', + title: subject.label || subject.id, + detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`, + target: { layerId: subject.layerId, id: String(subject.id) }, + distanceM: -1, + }); } + for (const cohort of snapshot.cohorts) { + if (!['flights', 'military'].includes(cohort.id)) continue; + for (const item of cohort.nearest) { + const id = item.icao24 || item.id; + if (!id) continue; + contacts.push({ + key: `flight:${cohort.id}:${id}`, + tone: cohort.id === 'military' ? 'nearby military' : 'nearby', + // `id` above is IDENTITY (the ICAO hex). Display must not reuse it: + // the row's own `id` already carries the layer's label convention + // (callsign → registration → hex), so a callsign-less enriched + // contact reads as its registration here too. Same helper the + // Context panel's nearest list uses. + title: this.services.formatAwarenessLabel(item), + detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${ + Number.isFinite(item.distanceM) + ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` + : 'DISTANCE UNKNOWN' + }`, + target: { layerId: cohort.id, id: String(id) }, + distanceM: item.distanceM ?? Infinity, + }); + } + } + contacts.sort((a, b) => a.distanceM - b.distanceM); + const nextItems = contacts.slice(0, 5).map((item) => ({ + ...item, + timestamp: + previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), + })); + if (unknownCount) { + const sources = snapshot.cohorts + .filter((cohort) => cohort.count === null) + .map((cohort) => cohort.source) + .join(' · '); + nextItems.splice(4, Math.max(0, nextItems.length - 4), { + key: 'input-status', + tone: 'warning', + title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`, + detail: sources || 'SOURCE STATUS UNAVAILABLE', + target: null, + timestamp: + previous.get('input-status')?.timestamp || + snapshot.evaluatedAt || + Date.now(), + }); + } + this.signalItems = nextItems; + this.signalSignatures.clear(); + this.renderCockpitSignals(); +} export function handleSignalClick(event) { if (this.destroyed) return; - const target = event.target.closest('button[data-signal-layer][data-signal-id]'); - if (!target) return; - event.preventDefault(); - this.services.militaryAwarenessLayer.focusTarget?.( - target.dataset.signalLayer, - target.dataset.signalId, - { origin: 'user' }, - ); + const target = event.target.closest( + 'button[data-signal-layer][data-signal-id]', + ); + if (!target) return; + event.preventDefault(); + this.services.militaryAwarenessLayer.focusTarget?.( + target.dataset.signalLayer, + target.dataset.signalId, + { origin: 'user' }, + ); } diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 78ddfc0fc..5a919ec46 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -1,226 +1,254 @@ /** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */ -import { normalizeHeading, resolveTrackedAircraftInfo } from '../cockpitMath.js'; +import { + normalizeHeading, + resolveTrackedAircraftInfo, +} from '../cockpitMath.js'; export function readAircraftInfo() { - // In cockpit mode the controller takes the entity off `viewer.trackedEntity` - // (see update()), so the cockpit's own handle is the tracked identity there. - const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; - return resolveTrackedAircraftInfo({ - civilian: this.services.flightsLayer.getTrackedInfo?.() || null, - military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null, - trackedId: trackedEntity?.gevTrackedId || '', - }); - } + // In cockpit mode the controller takes the entity off `viewer.trackedEntity` + // (see update()), so the cockpit's own handle is the tracked identity there. + const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; + return resolveTrackedAircraftInfo({ + civilian: this.services.flightsLayer.getTrackedInfo?.() || null, + military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null, + trackedId: trackedEntity?.gevTrackedId || '', + }); +} export function dispatchCockpitModeChanged(active, info = null) { - const subjectId = active - ? String(info?.icao24 || '').trim().toLowerCase() || null - : null; - const layerId = active && ['flights', 'military'].includes(info?.layerId) + const subjectId = active + ? String(info?.icao24 || '') + .trim() + .toLowerCase() || null + : null; + const layerId = + active && ['flights', 'military'].includes(info?.layerId) ? info.layerId : null; - window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { + window.dispatchEvent( + new CustomEvent('gev:cockpit-mode-changed', { detail: { active: active === true, subjectId, layerId }, - })); - } + }), + ); +} export function toggleTrackedTr3b() { - if (this.destroyed) return false; - const info = this.readAircraftInfo(); - const icao24 = String(info?.icao24 || '').trim(); - if (!icao24) return false; - this.services.toggleTr3b(icao24); - const layer = info.layerId === 'military' ? this.services.militaryFlightsLayer : this.services.flightsLayer; - layer.refreshTr3b?.(icao24); - this._tr3bSignature = null; // force the chip to repaint on the next sync - this.syncTr3bToggle(info); - return true; - } + if (this.destroyed) return false; + const info = this.readAircraftInfo(); + const icao24 = String(info?.icao24 || '').trim(); + if (!icao24) return false; + this.services.toggleTr3b(icao24); + const layer = + info.layerId === 'military' + ? this.services.militaryFlightsLayer + : this.services.flightsLayer; + layer.refreshTr3b?.(icao24); + this._tr3bSignature = null; // force the chip to repaint on the next sync + this.syncTr3bToggle(info); + return true; +} export function syncTr3bToggle(info) { - if (!this.tr3bToggle) return; - const icao24 = String(info?.icao24 || '').trim(); - const converted = !!icao24 && this.services.isTr3b(icao24); - const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : ''; - if (this._tr3bSignature === signature) return; - this._tr3bSignature = signature; - this.tr3bToggle.hidden = !icao24; - this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); - this.tr3bToggle.title = converted ? 'Restore real aircraft' : 'Reclassify as TR-3B'; - } + if (!this.tr3bToggle) return; + const icao24 = String(info?.icao24 || '').trim(); + const converted = !!icao24 && this.services.isTr3b(icao24); + const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : ''; + if (this._tr3bSignature === signature) return; + this._tr3bSignature = signature; + this.tr3bToggle.hidden = !icao24; + this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); + this.tr3bToggle.title = converted + ? 'Restore real aircraft' + : 'Reclassify as TR-3B'; +} export function syncEntry() { - if (this.destroyed) return false; - if (this.active) return; - const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); - this.syncTr3bToggle(trackedContact ? info : null); - const available = !!(this.isEntryAllowed() && trackedContact); - // Change-only DOM writes: this runs on a preUpdate cadence, and - // unconditional `hidden` assignments invalidate style/layout every frame - // even when nothing changed. (perf item 9) - if (this._entryAvailable === available) return; - this._entryAvailable = available; - if (this.entry) this.entry.hidden = !available; - if (this.mapViewButton) this.mapViewButton.hidden = true; - if (this.resetGlobeButton) this.resetGlobeButton.hidden = true; - } + if (this.destroyed) return false; + if (this.active) return; + const info = this.readAircraftInfo(); + const trackedContact = !!(info && this.viewer.trackedEntity?.position); + this.syncTr3bToggle(trackedContact ? info : null); + const available = !!(this.isEntryAllowed() && trackedContact); + // Change-only DOM writes: this runs on a preUpdate cadence, and + // unconditional `hidden` assignments invalidate style/layout every frame + // even when nothing changed. (perf item 9) + if (this._entryAvailable === available) return; + this._entryAvailable = available; + if (this.entry) this.entry.hidden = !available; + if (this.mapViewButton) this.mapViewButton.hidden = true; + if (this.resetGlobeButton) this.resetGlobeButton.hidden = true; +} export function navigateContext(direction, options = {}) { - if (this.destroyed) return false; - const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; - const wasActive = this.active; - if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; - const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options; - const changed = Boolean(this.services.militaryAwarenessLayer?.[method]?.(navigationOptions)); - if (!changed) { - this.contextNavigationDeadlineMs = 0; - return false; - } - if (wasActive) this._adoptTrackedEntity(performance.now()); - return true; + if (this.destroyed) return false; + const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; + const wasActive = this.active; + if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; + const navigationOptions = wasActive + ? { ...options, aircraftOnly: true } + : options; + const changed = Boolean( + this.services.militaryAwarenessLayer?.[method]?.(navigationOptions), + ); + if (!changed) { + this.contextNavigationDeadlineMs = 0; + return false; } + if (wasActive) this._adoptTrackedEntity(performance.now()); + return true; +} export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { - const nextEntity = this.viewer.trackedEntity; - if (!this.active || !nextEntity?.position || nextEntity === this.trackedEntity) return false; - const info = suppliedInfo || this.readAircraftInfo(); - if (!info) return false; - if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { - this.trackedEntity.show = this.trackedEntityWasShown; - } - this.trackedEntity = nextEntity; - this.trackedEntityWasShown = nextEntity.show; - nextEntity.show = false; - this.viewer.trackedEntity = undefined; - this.cockpitAnchorValid = false; - this.heading = normalizeHeading(info.track ?? 0); - this.lastFrameMs = nowMs; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.lastCameraUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.dispatchCockpitModeChanged(true, info); - return true; + const nextEntity = this.viewer.trackedEntity; + if ( + !this.active || + !nextEntity?.position || + nextEntity === this.trackedEntity + ) + return false; + const info = suppliedInfo || this.readAircraftInfo(); + if (!info) return false; + if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { + this.trackedEntity.show = this.trackedEntityWasShown; } + this.trackedEntity = nextEntity; + this.trackedEntityWasShown = nextEntity.show; + nextEntity.show = false; + this.viewer.trackedEntity = undefined; + this.cockpitAnchorValid = false; + this.heading = normalizeHeading(info.track ?? 0); + this.lastFrameMs = nowMs; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.lastCameraUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.dispatchCockpitModeChanged(true, info); + return true; +} export function enter() { - if (this.destroyed) return false; - if (this.active) return false; - if (!this.isEntryAllowed()) return false; - const info = this.readAircraftInfo(); - const entity = this.viewer.trackedEntity; - if (!info || !entity?.position) return false; - this.entryFocusOrigin = document.activeElement instanceof HTMLElement + if (this.destroyed) return false; + if (this.active) return false; + if (!this.isEntryAllowed()) return false; + const info = this.readAircraftInfo(); + const entity = this.viewer.trackedEntity; + if (!info || !entity?.position) return false; + this.entryFocusOrigin = + document.activeElement instanceof HTMLElement ? document.activeElement : null; - // Retire deferred navigation before cancelFlight can run its callbacks. - this.onCameraTakeover?.(); - this.viewer.camera.cancelFlight(); - this.trackedEntity = entity; - this.trackedEntityWasShown = entity.show; - entity.show = false; - this.heading = normalizeHeading(info.track ?? 0); - this.lastFrameMs = performance.now(); - this.lastCameraUpdateMs = 0; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.lastGroundProbeMs = 0; - this.surfaceWaitStartedMs = performance.now(); - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastCompassSignature = ''; - this.cockpitAnchorValid = false; - this.lastCameraUpdateMs = 0; - this.active = true; - // Cockpit animates the camera from preUpdate every frame — preUpdate only - // runs on rendered frames, so idle mode would freeze the cockpit solid. - // (perf wave 2) - this.services.holdContinuousRender('cockpit'); - this.viewer.trackedEntity = undefined; - this.viewer.scene.screenSpaceCameraController.enableInputs = false; - document.body.classList.add('cockpit-mode'); - // Activation writes entry/quick/map visibility directly, bypassing - // syncEntry's change-only cache — invalidate it so the exit-path - // syncEntry re-applies every write (notably re-hiding mapViewButton). - this._entryAvailable = undefined; - if (this.entry) this.entry.hidden = true; - if (this.tr3bToggle) { - this.tr3bToggle.hidden = true; - this._tr3bSignature = null; - } - if (this.mapViewButton) this.mapViewButton.hidden = false; - if (this.resetGlobeButton) this.resetGlobeButton.hidden = false; - if (this.hud) this.hud.hidden = false; - if (this.signalStream) this.signalStream.hidden = false; - this.hud?.classList.add('signals-active'); - this.signalItems = []; - this.signalSignatures.clear(); - this.showBriefPage(0); - this.startBriefRotation(); - const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; - const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0'); - this.pushCockpitSignal( - 'track', - 'track', - 'TRACK ACQUIRED', - `${trackLabel} · COURSE ${trackHeading}°`, - ); - this.updateHud(info, performance.now(), true); - this.setVisionMode(this.visionMode); - this.scheduleContextLayout(); - this.mapViewButton?.focus({ preventScroll: true }); - this.onEntered?.(); - this.dispatchCockpitModeChanged(true, info); - return true; + // Retire deferred navigation before cancelFlight can run its callbacks. + this.onCameraTakeover?.(); + this.viewer.camera.cancelFlight(); + this.trackedEntity = entity; + this.trackedEntityWasShown = entity.show; + entity.show = false; + this.heading = normalizeHeading(info.track ?? 0); + this.lastFrameMs = performance.now(); + this.lastCameraUpdateMs = 0; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.lastGroundProbeMs = 0; + this.surfaceWaitStartedMs = performance.now(); + this.surfaceAcquiring = false; + this.surfaceFallback = false; + this.lastCompassSignature = ''; + this.cockpitAnchorValid = false; + this.lastCameraUpdateMs = 0; + this.active = true; + // Cockpit animates the camera from preUpdate every frame — preUpdate only + // runs on rendered frames, so idle mode would freeze the cockpit solid. + // (perf wave 2) + this.services.holdContinuousRender('cockpit'); + this.viewer.trackedEntity = undefined; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + document.body.classList.add('cockpit-mode'); + // Activation writes entry/quick/map visibility directly, bypassing + // syncEntry's change-only cache — invalidate it so the exit-path + // syncEntry re-applies every write (notably re-hiding mapViewButton). + this._entryAvailable = undefined; + if (this.entry) this.entry.hidden = true; + if (this.tr3bToggle) { + this.tr3bToggle.hidden = true; + this._tr3bSignature = null; } + if (this.mapViewButton) this.mapViewButton.hidden = false; + if (this.resetGlobeButton) this.resetGlobeButton.hidden = false; + if (this.hud) this.hud.hidden = false; + if (this.signalStream) this.signalStream.hidden = false; + this.hud?.classList.add('signals-active'); + this.signalItems = []; + this.signalSignatures.clear(); + this.showBriefPage(0); + this.startBriefRotation(); + const trackLabel = + info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; + const trackHeading = String( + Math.round(normalizeHeading(info.track ?? 0)), + ).padStart(3, '0'); + this.pushCockpitSignal( + 'track', + 'track', + 'TRACK ACQUIRED', + `${trackLabel} · COURSE ${trackHeading}°`, + ); + this.updateHud(info, performance.now(), true); + this.setVisionMode(this.visionMode); + this.scheduleContextLayout(); + this.mapViewButton?.focus({ preventScroll: true }); + this.onEntered?.(); + this.dispatchCockpitModeChanged(true, info); + return true; +} export function exit({ restoreTracking = true } = {}) { - if (!this.active) return false; - const entity = this.trackedEntity; - this.active = false; - this.services.releaseContinuousRender('cockpit'); - this.trackedEntity = null; - this.heading = null; - this.cockpitAnchorValid = false; - this.surfaceWaitStartedMs = 0; - this.surfaceAcquiring = false; - this.surfaceFallback = false; - this.lastHudUpdateMs = 0; - this.lastContextUpdateMs = 0; - this.contextNavigationDeadlineMs = 0; - this.lastCompassSignature = ''; - this.stopBriefRotation(); - this.regionalBriefAbort?.abort(); - this.regionalBriefAbort = null; - this.regionalBriefRequestToken += 1; - this.regionalBriefSubjectId = null; - document.body.classList.remove('cockpit-mode'); - this.onExited?.(); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - if (this.hud) this.hud.hidden = true; - if (this.route) this.route.hidden = true; - this.clearPredictiveRoute(); - this.setVisionMode('optical'); - if (this.signalStream) this.signalStream.hidden = true; - this.hud?.classList.remove('signals-active'); - this.viewer.scene.screenSpaceCameraController.enableInputs = true; - if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; - this.trackedEntityWasShown = true; - this.dispatchCockpitModeChanged(false); - if (restoreTracking && entity && this.viewer.entities.contains(entity)) { - this.viewer.trackedEntity = entity; - this.restoreTrackingFrame(entity); - } - this.syncEntry(); - const restoreTarget = this.entryFocusOrigin === this.entry - ? this.entry - : (this.entry || this.entryFocusOrigin); - this.entryFocusOrigin = null; - if (restoreTarget?.isConnected && !restoreTarget.hidden) { - restoreTarget.focus({ preventScroll: true }); - } - return true; + if (!this.active) return false; + const entity = this.trackedEntity; + this.active = false; + this.services.releaseContinuousRender('cockpit'); + this.trackedEntity = null; + this.heading = null; + this.cockpitAnchorValid = false; + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.lastCompassSignature = ''; + this.stopBriefRotation(); + this.regionalBriefAbort?.abort(); + this.regionalBriefAbort = null; + this.regionalBriefRequestToken += 1; + this.regionalBriefSubjectId = null; + document.body.classList.remove('cockpit-mode'); + this.onExited?.(); + this.hud?.style.removeProperty('--cockpit-utility-top'); + this.hud?.style.removeProperty('--cockpit-utility-max-height'); + if (this.hud) this.hud.hidden = true; + if (this.route) this.route.hidden = true; + this.clearPredictiveRoute(); + this.setVisionMode('optical'); + if (this.signalStream) this.signalStream.hidden = true; + this.hud?.classList.remove('signals-active'); + this.viewer.scene.screenSpaceCameraController.enableInputs = true; + if (entity && this.viewer.entities.contains(entity)) + entity.show = this.trackedEntityWasShown; + this.trackedEntityWasShown = true; + this.dispatchCockpitModeChanged(false); + if (restoreTracking && entity && this.viewer.entities.contains(entity)) { + this.viewer.trackedEntity = entity; + this.restoreTrackingFrame(entity); } + this.syncEntry(); + const restoreTarget = + this.entryFocusOrigin === this.entry + ? this.entry + : this.entry || this.entryFocusOrigin; + this.entryFocusOrigin = null; + if (restoreTarget?.isConnected && !restoreTarget.hidden) { + restoreTarget.focus({ preventScroll: true }); + } + return true; +} From 7e2c38d8a466e159616de4ec0a30c4074d688a5d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 02:37:44 -0700 Subject: [PATCH 45/92] test: retain Cockpit invariants across formatted components --- src/cameraHandoff.test.mjs | 7 ++++--- src/cockpitMarkup.test.mjs | 16 ++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 5b3018964..3ef065443 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -130,16 +130,17 @@ test('voice Cockpit next/previous shares the manual Context navigation path', () // the arbiter. assert.match( CockpitViewController.toString(), - /this\._listen\(this\.contextPrevious, 'click', \(\) => this\.navigateContext\(-1, \{ origin: 'user' \}\)\);/, + /this\._listen\(this\.contextPrevious, 'click', \(\) =>\s*this\.navigateContext\(-1, \{ origin: 'user' \}\),?\s*\);/, ); assert.match( CockpitViewController.toString(), - /this\._listen\(this\.contextNext, 'click', \(\) => this\.navigateContext\(1, \{ origin: 'user' \}\)\);/, + /this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/, ); const funnel = navigateContext.toString(); + assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/); ordered(funnel, [ "const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';", - 'const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;', + 'const navigationOptions = wasActive', 'militaryAwarenessLayer?.[method]?.(navigationOptions)', 'this._adoptTrackedEntity(performance.now());', ], 'Cockpit Context navigation funnel'); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index cefe27afd..7e2cd1647 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -81,7 +81,7 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => { test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => { assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/); - assert.match(setVisionMode.toString(), /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/); + assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/); assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/); assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/); assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/); @@ -131,11 +131,11 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => { const keydown = cockpitKeyDown.toString(); assert.ok(keydown, 'Cockpit keyboard handler is missing'); - assert.match(keydown, /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/); + assert.match(keydown, /document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/); assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/); assert.match( keydown, - /const cockpitAttempt = !!\(this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, + /const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, ); }); @@ -284,7 +284,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', ); assert.match(css, /\.cockpit-utility-controls\.layout-primary-only[\s\S]*?display:\s*none/); assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*?\.cockpit-utility-controls:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?display:\s*none/); - assert.match(syncSignalLayout.toString(), /resolveCockpitUtilityLayout\(\{ availableHeight, expandedHeight, collapsedHeight \}\)/); + assert.match(syncSignalLayout.toString(), /resolveCockpitUtilityLayout\(\{\s*availableHeight,\s*expandedHeight,\s*collapsedHeight,?\s*\}\)/); assert.match(syncSignalLayout.toString(), /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/); }); @@ -547,7 +547,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { assert.ok(signalLayout, 'Cockpit signal layout method is missing'); assert.match( signalLayout, - /setProperty\('--cockpit-utility-top', `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`\)/, + /setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/, 'Cockpit owns the utility strip anchor and republishes it every layout tick', ); assert.match( @@ -558,13 +558,13 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { assert.match(signalLayout, /#intel-hud \.hud-top-right/); assert.match( signalLayout, - /const recBounds = isRenderedOnScreen\(recReadout\) \? recReadout\.getBoundingClientRect\(\) : null;/, + /const recBounds = isRenderedOnScreen\(recReadout\)\s*\? recReadout\.getBoundingClientRect\(\)\s*: null;/, 'HUD Off retires the Intel HUD with visibility/opacity, which leaves the REC ' + 'readout a rect — a rect test alone would anchor the strip to an invisible readout', ); assert.match( isRenderedOnScreen.toString(), - /function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none' \|\| style\.visibility === 'hidden' \|\| Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/, + /function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none'\s*\|\|\s*style\.visibility === 'hidden'\s*\|\|\s*Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/, ); assert.match( ui, @@ -645,7 +645,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls assert.doesNotMatch(ui, /\['presets',/); assert.match( CockpitDisplayPortal.toString(), - /group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed'/, + /group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/, ); assert.match( CockpitDisplayPortal.prototype.setActive.toString(), From 947fc791826afba54638d11d179666b997b9d960 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 02:46:57 -0700 Subject: [PATCH 46/92] test: exercise Cockpit identity publication through its actual owner --- src/data/cockpitDetectionSubjects.test.mjs | 30 ++++++++++++++-------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/data/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs index 5b43167ab..12727e0b1 100644 --- a/src/data/cockpitDetectionSubjects.test.mjs +++ b/src/data/cockpitDetectionSubjects.test.mjs @@ -1,3 +1,4 @@ +import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -14,7 +15,6 @@ import militaryFlightsLayer, { const SUBJECT = 'abc123'; const NEXT_SUBJECT = 'def456'; -const UI_SOURCE = readFileSync(new URL('../ui.js', import.meta.url), 'utf8'); const FLIGHTS_SOURCE = readFileSync(new URL('./flights.js', import.meta.url), 'utf8'); const MILITARY_SOURCE = readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'); @@ -84,16 +84,24 @@ function candidateIds(layer) { } test('Cockpit lifecycle publishes one normalized aircraft identity to both detection owners', () => { - const dispatcher = /dispatchCockpitModeChanged\(active, info = null\) \{[\s\S]*?\n \}/ - .exec(UI_SOURCE)?.[0]; - assert.ok(dispatcher, 'Cockpit event dispatcher is defined'); - assert.match(dispatcher, /info\?\.icao24/); - assert.match(dispatcher, /\.trim\(\)\.toLowerCase\(\)/); - assert.match(dispatcher, /\['flights', 'military'\]\.includes\(info\?\.layerId\)/); - assert.match(dispatcher, /detail: \{ active: active === true, subjectId, layerId \}/); - assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(true, info\);/, - 'entry and in-Cockpit handoff publish the active subject'); - assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(false\);/, + const previousWindow = globalThis.window; + const details = []; + globalThis.window = { dispatchEvent: event => details.push(event.detail) }; + try { + dispatchCockpitModeChanged(true, { icao24: ' ABC123 ', layerId: 'military' }); + dispatchCockpitModeChanged(true, { icao24: ' DEF456 ', layerId: 'other' }); + dispatchCockpitModeChanged(false); + assert.deepEqual(details, [ + { active: true, subjectId: 'abc123', layerId: 'military' }, + { active: true, subjectId: 'def456', layerId: null }, + { active: false, subjectId: null, layerId: null }, + ]); + } finally { globalThis.window = previousWindow; } + for (const action of [enter, _adoptTrackedEntity]) { + assert.match(action.toString(), /this\.dispatchCockpitModeChanged\(true, info\);/, + 'entry and in-Cockpit handoff each publish the active subject'); + } + assert.match(exit.toString(), /this\.dispatchCockpitModeChanged\(false\);/, 'exit clears the active subject'); for (const [name, source] of [ From 152d3e2352c2bff94faed52833764f0008bb2af7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 02:46:57 -0700 Subject: [PATCH 47/92] fix: stop Cockpit work before asynchronous UI teardown --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 4 +- scripts/qa-ui-disposal.mjs | 69 +++++++++++++++++++++++++++- src/cockpitMarkup.test.mjs | 2 +- src/ui.js | 2 + src/ui/cockpitController.js | 12 ++++- src/ui/cockpitController.test.mjs | 23 ++++++++++ src/ui/cockpitDisplayPortal.js | 27 ++++++++--- src/ui/cockpitDisplayPortal.test.mjs | 21 ++++++++- src/ui/cockpitLayout.js | 1 + src/ui/cockpitTrackingController.js | 2 +- 11 files changed, 150 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c0a3d18..5bae06ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] - Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations. -- Give Display portal moves cancellable focus/scroll restoration and terminal cleanup. +- Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown. - Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f17e4448d..a383d4012 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7,7 +7,9 @@ layer, terrain and rendering operations are supplied by composition, retaining the same tracked identity, ground acquisition, motion correction and cadence. The Display portal owns group anchors, focus/scroll restoration and listeners. Superseded portal frames cannot repaint old state or steal focus after disposal; -retained Cockpit actions cannot restart a disposed controller. +retained Cockpit actions cannot restart a disposed controller. Input, subscriptions +and queued panel work stop before asynchronous layer restoration; final camera +and portal cleanup follows that restoration. ## Context coordination diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 6e75e8e26..2808adfa0 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -75,6 +75,33 @@ try { return unsubscribe?.(); }; } + const cockpit = ui.cockpitView; + const portal = ui._cockpitDisplayPortal; + const cockpitListenerCount = cockpit._listenerRemovers.length; + counts.cockpit = 0; + cockpit._listenerRemovers = cockpit._listenerRemovers.map( + (remove) => () => { + counts.cockpit++; + return remove(); + }, + ); + const portalHomes = portal.records.map((record) => ({ + group: record.group, + home: record.anchor.parentNode, + anchor: record.anchor, + })); + portal.setActive(true); + let releaseRestoration; + const restorationGate = new Promise((resolve) => { + releaseRestoration = resolve; + }); + const restoreForDisposal = ui._contextControls.restoreForDisposal.bind( + ui._contextControls, + ); + ui._contextControls.restoreForDisposal = async () => { + await restorationGate; + return restoreForDisposal(); + }; const resizeHandler = ui._windowResizeHandler; const removeEventListener = window.removeEventListener; counts.resize = 0; @@ -83,7 +110,21 @@ try { return removeEventListener.call(this, type, callback, options); }; try { - await ui.dispose(); + const disposal = ui.dispose(); + const focusBefore = document.activeElement; + const stoppedBeforeRestoration = + cockpit.destroyed && + portal.stopped && + cockpit._listenerRemovers.length === 0 && + portal.frames.size === 0 && + cockpit.enter() === false && + cockpit.navigateContext(1) === false; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + const noDeferredFocus = document.activeElement === focusBefore; + releaseRestoration(); + await disposal; const once = JSON.stringify(counts); await ui.dispose(); window.dispatchEvent(new Event('resize')); @@ -91,6 +132,23 @@ try { requestAnimationFrame(() => requestAnimationFrame(resolve)), ); return { + cockpitStoppedBeforeRestoration: + stoppedBeforeRestoration && noDeferredFocus, + cockpitReleased: + cockpit.disposed && + cockpit.destroyed && + counts.cockpit === cockpitListenerCount && + cockpitListenerCount > 0 && + portal.destroyed && + portal.listeners.signal.aborted && + portal.frames.size === 0 && + ui._cockpitDisplayPortal === null && + portalHomes.length === 4 && + portalHomes.every( + (record) => + record.group.parentNode === record.home && + !record.anchor.isConnected, + ), connected: Boolean( cctvUnsubscribe && resizeHandler && @@ -116,6 +174,7 @@ try { idempotent: once === JSON.stringify(counts), }; } finally { + releaseRestoration(); window.removeEventListener = removeEventListener; } }); @@ -143,6 +202,14 @@ try { 'Radio, Location and CCTV controls are disposed with the UI', result.controlsReleased, ); + check( + 'Cockpit actions and queued portal work stop before layer restoration', + result.cockpitStoppedBeforeRestoration, + ); + check( + 'Cockpit cleanup releases listeners and returns all Display groups home', + result.cockpitReleased, + ); check('repeated UI disposal is inert', result.idempotent); check( 'disposal and subsequent resize produce no uncaught browser errors', diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 7e2cd1647..adfea26bc 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -653,7 +653,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls ); assert.match( CockpitDisplayPortal.prototype.destroy.toString(), - /setActive\(false, \{ settle: false \}\)[\s\S]*?listeners\.abort\(\)[\s\S]*?record\.anchor\.remove\(\)/, + /this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/, ); assert.doesNotMatch(ui, /_cycleCockpitHud|_cockpitModels3dToggle|_cockpitDetectionToggle/); assert.equal((html.match(/id="style-buttons"/g) || []).length, 1); diff --git a/src/ui.js b/src/ui.js index 66f0b5955..caae0a196 100644 --- a/src/ui.js +++ b/src/ui.js @@ -5130,6 +5130,8 @@ export class StyleManager { this._locationControls?.destroy(); this._cctvControls?.destroy(); this._radioControls?.destroy(); + this.cockpitView?.stop(); + this._cockpitDisplayPortal?.stop(); this._visualEffects.stop(); this._styleParameters?.destroy(); for (const control of this._panelDisclosureControls || []) control.destroy(); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 4f5cc33fc..b5f9077a8 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -63,6 +63,7 @@ export class CockpitViewController { this.viewer = viewer; this.services = services; this.destroyed = false; + this.disposed = false; this.active = false; this.trackedEntity = null; this.trackedEntityWasShown = true; @@ -407,10 +408,10 @@ export class CockpitViewController { return handleSignalClick.call(this, event); } - dispose() { + /** Revoke new work before the shell awaits layer restoration. */ + stop() { if (this.destroyed) return; this.destroyed = true; - this.exit({ restoreTracking: false }); this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; this.regionalBriefRequestToken += 1; @@ -421,4 +422,11 @@ export class CockpitViewController { for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.(); } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.stop(); + this.exit({ restoreTracking: false }); + } } diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index 4f8018cbf..e92be00a2 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -141,3 +141,26 @@ test('an obsolete regional briefing cannot publish after Cockpit disposal', asyn env.restore(); } }); + +test('stopping Cockpit revokes camera actions before deferred disposal releases rendering', () => { + const env = environment(); + try { + const released = []; + const owner = new CockpitViewController(env.viewer, { + services: { + releaseContinuousRender: (reason) => released.push(reason), + }, + }); + owner.active = true; + owner.stop(); + assert.equal(env.callbacks.size, 0); + assert.equal(owner.update(), false); + assert.equal(owner.navigateContext(1), false); + assert.deepEqual(released, []); + owner.dispose(); + assert.deepEqual(released, ['cockpit']); + assert.equal(owner.active, false); + } finally { + env.restore(); + } +}); diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js index 50b13d8b6..8c653c6f6 100644 --- a/src/ui/cockpitDisplayPortal.js +++ b/src/ui/cockpitDisplayPortal.js @@ -6,6 +6,7 @@ export class CockpitDisplayPortal { this.layout = layout; this.active = false; this.destroyed = false; + this.stopped = false; this.restoreOwner = null; this.generation = 0; this.frames = new Set(); @@ -49,7 +50,7 @@ export class CockpitDisplayPortal { _frame(generation, callback) { const frame = requestAnimationFrame(() => { this.frames.delete(frame); - if (!this.destroyed && generation === this.generation) callback(); + if (!this.stopped && generation === this.generation) callback(); }); this.frames.add(frame); } @@ -61,7 +62,7 @@ export class CockpitDisplayPortal { } setActive(active, { settle = true } = {}) { - if (this.destroyed) return; + if (this.stopped) return; const nextActive = active === true; if (this.active === nextActive) return; this._cancelFrames(); @@ -102,13 +103,25 @@ export class CockpitDisplayPortal { this.layout(); } - destroy() { - if (this.destroyed) return; - this.setActive(false, { settle: false }); - this.destroyed = true; + stop() { + if (this.stopped) return; + this.stopped = true; this._cancelFrames(); this.listeners.abort(); - for (const record of this.records) record.anchor.remove(); + } + + destroy() { + if (this.destroyed) return; + this.stop(); + this.destroyed = true; + this.active = false; + for (const record of this.records) { + if (record.anchor.parentNode) record.anchor.after(record.group); + record.anchor.remove(); + } + this.cockpitPanel?.classList.remove('uses-shared-display-controls'); + if (this.standardPanel) + this.standardPanel.scrollTop = this.standardScrollTop; this.records = []; this.restoreOwner = null; } diff --git a/src/ui/cockpitDisplayPortal.test.mjs b/src/ui/cockpitDisplayPortal.test.mjs index 219508ced..c9837891c 100644 --- a/src/ui/cockpitDisplayPortal.test.mjs +++ b/src/ui/cockpitDisplayPortal.test.mjs @@ -19,7 +19,7 @@ function fixture() { this.children = []; this.parentNode = null; this.scrollTop = 0; - this.classList = { toggle() {} }; + this.classList = { toggle() {}, remove() {} }; } append(node) { node.remove(); @@ -181,3 +181,22 @@ test('disposal restores groups and revokes mode listeners and queued focus work' f.restore(); } }); + +test('a stopped portal waits for cleanup without running queued focus work', () => { + const f = fixture(); + try { + const owner = f.create(); + owner.setActive(true); + const queued = [...f.frames.values()]; + owner.stop(); + queued.forEach((callback) => callback()); + owner.setActive(false); + assert.equal(f.group.parentNode, f.slot); + assert.equal(f.frames.size, 0); + assert.equal(f.focusCalls, 0); + owner.destroy(); + assert.deepEqual(f.standard.children, [f.group, f.sibling]); + } finally { + f.restore(); + } +}); diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js index 058c69c99..cb6af30e1 100644 --- a/src/ui/cockpitLayout.js +++ b/src/ui/cockpitLayout.js @@ -20,6 +20,7 @@ export function scheduleContextLayout() { return; this.contextLayoutFrame = requestAnimationFrame(() => { this.contextLayoutFrame = null; + if (this.destroyed) return; this.syncContextLayout(); this.syncSignalLayout(); }); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 5a919ec46..4170333a8 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -247,7 +247,7 @@ export function exit({ restoreTracking = true } = {}) { ? this.entry : this.entry || this.entryFocusOrigin; this.entryFocusOrigin = null; - if (restoreTarget?.isConnected && !restoreTarget.hidden) { + if (!this.destroyed && restoreTarget?.isConnected && !restoreTarget.hidden) { restoreTarget.focus({ preventScroll: true }); } return true; From 393c31d86e398309ef4a51220ffd175365c4e226 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 03:08:42 -0700 Subject: [PATCH 48/92] fix: prevent stopped Cockpit briefings from restarting --- src/ui/cockpitBriefing.js | 5 ++++ src/ui/cockpitController.test.mjs | 45 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index d6f222cfc..c467ed865 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -11,6 +11,7 @@ import { } from './cockpitPresentation.js'; export function showBriefPage(index, { manual = false } = {}) { + if (this.destroyed) return; const count = COCKPIT_BRIEF_PAGES.length; this.briefPageIndex = ((Number(index) % count) + count) % count; const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; @@ -41,6 +42,7 @@ export function showBriefPage(index, { manual = false } = {}) { } export function setBriefAutoRotate(enabled) { + if (this.destroyed) return; this.briefAutoRotateEnabled = Boolean(enabled); if (this.briefAutoToggle) { this.briefAutoToggle.setAttribute( @@ -60,6 +62,7 @@ export function setBriefAutoRotate(enabled) { } export function startBriefRotation({ reset = false } = {}) { + if (this.destroyed) return; if (reset) this.stopBriefRotation(); if ( !this.briefAutoRotateEnabled || @@ -71,6 +74,7 @@ export function startBriefRotation({ reset = false } = {}) { return; this.briefTimer = window.setTimeout(() => { this.briefTimer = null; + if (this.destroyed) return; const hasPointer = this.signalStream?.matches(':hover') === true; const hasFocus = this.signalStream?.contains(document.activeElement) === true; @@ -99,6 +103,7 @@ export function updateLocalPosition(info) { } export function maybeRefreshRegionalBrief(info) { + if (this.destroyed) return; if ( !this.active || !Number.isFinite(info.latitude) || diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index e92be00a2..c700bb537 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -164,3 +164,48 @@ test('stopping Cockpit revokes camera actions before deferred disposal releases env.restore(); } }); + +test('stopped Cockpit cannot rearm briefing rotation or request a fresh region', () => { + const env = environment(); + try { + const frames = new Map(); + let next = 0; + let fetches = 0; + let paints = 0; + env.viewer.scene.screenSpaceCameraController.enableInputs = true; + window.setTimeout = (callback) => { + frames.set(++next, callback); + return next; + }; + window.clearTimeout = (id) => frames.delete(id); + const owner = new CockpitViewController(env.viewer, { + services: { + regionalDistanceM: () => Infinity, + fetchRegionalBrief: () => { + fetches++; + return Promise.resolve({ articles: [] }); + }, + releaseContinuousRender() {}, + }, + }); + owner.active = true; + owner.showBriefPage = () => { + paints++; + }; + owner.setBriefAutoRotate(true); + assert.equal(frames.size, 1); + const queued = [...frames.values()][0]; + owner.stop(); + assert.equal(frames.size, 0); + queued(); + owner.setBriefAutoRotate(true); + owner.startBriefRotation(); + owner.maybeRefreshRegionalBrief({ latitude: 30, longitude: -97 }); + assert.equal(frames.size, 0); + assert.equal(paints, 0); + assert.equal(fetches, 0); + owner.dispose(); + } finally { + env.restore(); + } +}); From cd72feb4feeab3551934d49aceb167a4e11798af Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 03:29:46 -0700 Subject: [PATCH 49/92] refactor: separate Scene controls from playback and project operations --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 7 + docs/CURRENT-STATE.md | 8 + package.json | 3 +- scripts/package-boundaries.json | 9 ++ scripts/qa-scene-controls.mjs | 128 +++++++++++++++ src/cockpitMarkup.test.mjs | 6 +- src/scenes/director.js | 267 ++++++-------------------------- src/ui/sceneControls.js | 116 ++++++++++++++ src/ui/sceneControls.test.mjs | 145 +++++++++++++++++ src/ui/scenePresentation.js | 96 ++++++++++++ src/ui/scenes.js | 1 + 12 files changed, 566 insertions(+), 222 deletions(-) create mode 100644 scripts/qa-scene-controls.mjs create mode 100644 src/ui/sceneControls.js create mode 100644 src/ui/sceneControls.test.mjs create mode 100644 src/ui/scenePresentation.js create mode 100644 src/ui/scenes.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bae06ad2..b3602f2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback. + - Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations. - Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 2c89f7ff4..cce4e8f20 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -320,3 +320,10 @@ cache and sampling operations, continuous-render owner and regional briefing service. Pure math, utility layout and vision helpers have explicit exports. Disposal releases subscriptions and pending work; portal moves preserve the original Display groups, independent scroll positions and current focus owner. + +## Scene controls + +`ui/scenes` owns Scene panel input, project/shot rows, playback button and runtime +presentation. It receives project reads and explicit actions, with no imports of +the director, source modules, camera engine or storage. Replacement and disposal +release listeners; pending action feedback is limited to its current owner. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a383d4012..5e267ab59 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +## Scene control ownership + +Scene controls own panel listeners, shot rows, playback/recording presentation +and keyboard cancellation. The director supplies project reads and explicit +editing/playback actions while retaining persistence, camera and layer sequencing. +Replacing rows revokes their old listeners. Disposal stops controls immediately; +late file and failed-action completions cannot update removed presentation. + ## Cockpit component ownership Cockpit presentation is separated from its camera/controller behavior. Existing diff --git a/package.json b/package.json index b8d871123..d0bead40e 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "./ui/cockpit": "./src/ui/cockpit.js", "./ui/cockpit/math": "./src/cockpitMath.js", "./ui/cockpit/layout": "./src/cockpitUtilityLayout.js", - "./ui/cockpit/vision": "./src/cockpitVisionPolicy.js" + "./ui/cockpit/vision": "./src/cockpitVisionPolicy.js", + "./ui/scenes": "./src/ui/scenes.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 43373f43e..16b60d839 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -414,5 +414,14 @@ "src/ui/panelRailGeometry.js" ], "external": ["cesium"] + }, + "scene-controls": { + "exports": ["./ui/scenes"], + "modules": [ + "src/ui/scenes.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js" + ], + "external": [] } } diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs new file mode 100644 index 000000000..af124f04e --- /dev/null +++ b/scripts/qa-scene-controls.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** Exercise installed Scene controls and real project/playback operations. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const shots = path.resolve('qa-shots/scene-controls'); +fs.mkdirSync(shots, { recursive: true }); +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 promptValue = 'QA Scene'; +page.on('dialog', async (dialog) => { await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined); }); +let failures = 0; +function check(name, ok) { console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); if (!ok) failures++; } +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector?._controls && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }); + await page.click('[data-collapse-target="scene-panel"]'); + await page.click('#scene-new-btn'); + await page.waitForFunction(() => document.querySelector('#scene-select option:checked')?.textContent === 'QA Scene'); + check('New creates and selects a persisted empty scene', await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const saved = JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')); + return director._getSelectedScene().shots.length === 0 && !!document.querySelector('.scene-shot-empty') && saved.scenes.some((scene) => scene.title === 'QA Scene'); + })); + await page.click('#scene-capture-btn'); + await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 1); + check('Capture stores the actual camera and visual state', await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const shot = director._getSelectedScene().shots[0]; + const current = director.styleManager.getCameraState(); + return Number.isFinite(shot.camera.lat) && Math.abs(shot.camera.lat - current.lat) < 0.01 && shot.visual.style === director.styleManager.getVisualState().style; + })); + promptValue = 'QA shot'; + await page.click('.scene-shot-label', { clickCount: 2 }); + await page.waitForFunction(() => document.querySelector('.scene-shot-label')?.textContent === 'QA shot'); + check('Rename persists literal text without interpreting markup', await page.evaluate(() => !document.querySelector('.scene-shot-label b') && JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes.some((scene) => scene.shots.some((shot) => shot.title === 'QA shot')))); + await page.click('#scene-capture-btn'); + await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 2); + await page.click('.scene-shot-label'); + check('Shot selection and Update use the selected shot', await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const scene = director._getSelectedScene(); + const count = scene.shots.length; + document.getElementById('scene-update-shot-btn').click(); + return director._selectedShotId === scene.shots[0].id && scene.shots.length === count && document.querySelector('.scene-shot-row.active .scene-shot-label').textContent === scene.shots[0].title; + })); + await page.evaluate(() => { + window.__sceneExports = []; + const create = URL.createObjectURL.bind(URL); + URL.createObjectURL = (blob) => { + if (blob.type === 'application/json') window.__sceneExports.push(blob.text()); + return create(blob); + }; + }); + await page.click('#scene-export-btn'); + const exported = await page.evaluate(async () => JSON.parse(await window.__sceneExports[0])); + check('Export produces the current project JSON', exported.scenes.some((scene) => scene.title === 'QA Scene' && scene.shots.length === 2)); + // Keep real camera/style playback while isolating this control check from + // live provider availability. Layer transactions are covered by their suites. + const scene = exported.scenes.find((item) => item.title === 'QA Scene'); + scene.shots = scene.shots.slice(0, 1).map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} })); + const fixture = { ...exported, scenes: [scene] }; + const projectFile = path.join(shots, 'project.json'); + fs.writeFileSync(projectFile, JSON.stringify(fixture)); + const input = await page.$('#scene-import-file'); + await input.uploadFile(projectFile); + await page.waitForFunction(() => document.getElementById('scene-status').textContent === 'Imported project.json'); + check('Import replaces the project and clears the file input', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && document.querySelectorAll('.scene-shot-row').length === 1 && document.getElementById('scene-import-file').value === '')); + await page.click('.scene-shot-btn'); + await page.waitForFunction(() => document.getElementById('scene-status').textContent.startsWith('Loaded:'), { timeout: 30000 }); + check('LOAD completes the real visual and camera operation', await page.evaluate(() => !window.__godsEyeView.sceneDirector.running && document.getElementById('scene-status').textContent.includes('QA shot'))); + await page.click('#scene-start-btn'); + await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running && document.body.classList.contains('scene-playback-mode')); + check('Playback enters recording presentation and disables editing', await page.evaluate(() => document.getElementById('scene-capture-btn').disabled && document.getElementById('scene-start-btn').disabled && !document.getElementById('scene-stop-btn').disabled && window.__godsEyeView.styleManager.getViewState().recording)); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running && !document.body.classList.contains('scene-playback-mode')); + check('Escape ends playback and restores idle/recording controls', await page.evaluate(() => !document.getElementById('scene-start-btn').disabled && document.getElementById('scene-stop-btn').disabled && !document.getElementById('scene-download-btn').disabled && !window.__godsEyeView.styleManager.getViewState().recording)); + await page.click('#scene-download-btn'); + const metadata = await page.evaluate(async () => JSON.parse(await window.__sceneExports.at(-1))); + check('Run download contains the completed cancelled run', Boolean(metadata.endedAt && metadata.wasCancelled && Array.isArray(metadata.events))); + await page.click('#scene-start-btn'); + await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running); + await page.$eval('#scene-stop-btn', (button) => button.click()); + await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running); + check('The installed Stop control also completes playback cleanup', await page.evaluate(() => !document.body.classList.contains('scene-playback-mode') && !document.getElementById('scene-start-btn').disabled)); + const badFile = path.join(shots, 'invalid.json'); + fs.writeFileSync(badFile, '{invalid'); + await input.uploadFile(badFile); + await page.waitForFunction(() => document.getElementById('scene-status').textContent.includes('invalid JSON')); + check('Invalid import reports failure and preserves the current project', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && window.__godsEyeView.sceneDirector._getSelectedScene().title === 'QA Scene')); + await page.screenshot({ path: path.join(shots, 'desktop.png') }); + await page.setViewport({ width: 390, height: 844 }); + await page.screenshot({ path: path.join(shots, 'narrow.png') }); + check('Scene controls stay inside the narrow viewport', await page.$eval('#scene-select', (element) => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth; })); + await page.setViewport({ width: 1440, height: 900 }); + await page.click('.scene-shot-danger'); + await page.waitForFunction(() => !!document.querySelector('.scene-shot-empty')); + check('Delete shot leaves the empty-state presentation', await page.evaluate(() => window.__godsEyeView.sceneDirector._getSelectedScene().shots.length === 0)); + await page.click('#scene-delete-btn'); + await page.waitForFunction(() => !window.__godsEyeView.sceneDirector._project.scenes.some((item) => item.title === 'QA Scene')); + check('Deleting the final scene restores the built-in recipes', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length > 0 && document.querySelectorAll('#scene-select option').length > 0)); + const teardown = await page.evaluate(async () => { + const director = window.__godsEyeView.sceneDirector; + const controls = director._controls; + const previousStatus = document.getElementById('scene-status').textContent; + const project = director._project; + let finish; + const gate = new Promise((resolve) => { finish = resolve; }); + const pending = controls.run('import', { name: 'late.json', text: async () => { await gate; return '{"scenes":[]}'; } }); + const stopping = director.destroy(); + const stoppedSynchronously = controls.destroyed && controls.removers.length === 0 && controls.rowRemovers.length === 0; + document.getElementById('scene-new-btn').click(); + controls.run('capture'); + finish(); + await pending; + await stopping; + await director.destroy(); + return { stoppedSynchronously, sameProject: director._project === project, noLateStatus: document.getElementById('scene-status').textContent === previousStatus, rowsGone: !document.querySelector('.scene-shot-row') }; + }); + check('Disposal revokes fixed and row actions before waiting for scene work', teardown.stoppedSynchronously && teardown.rowsGone); + check('Late import completion cannot replace the disposed project or status', teardown.sameProject && teardown.noLateStatus); + check('Scene control interaction produces no uncaught browser errors', errors.length === 0); +} finally { await browser.close(); } +if (failures) process.exitCode = 1; diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index adfea26bc..e67fae046 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,3 +1,4 @@ +import { SceneControls } from './ui/sceneControls.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js'; @@ -238,8 +239,9 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', css, /body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/, ); - assert.match(sceneDirector, /this\._running = true;\s*document\.body\.classList\.add\('scene-playback-mode'\);/); - assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*document\.body\.classList\.remove\('scene-playback-mode'\);/); + assert.match(sceneDirector, /this\._running = true;\s*this\._controls\?\.setPlaybackActive\(true\);/); + assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._controls\?\.setPlaybackActive\(false\);/); + assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/); assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner'); assert.equal((html.match(/id="detection-toggle"/g) || []).length, 1, 'Detection control must have one stateful DOM owner'); assert.equal((html.match(/id="models3d-toggle"/g) || []).length, 1, '3D control must have one stateful DOM owner'); diff --git a/src/scenes/director.js b/src/scenes/director.js index 4109c307f..9160fcc4d 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -12,6 +12,7 @@ * State is persisted to localStorage and can be exported/imported as JSON. */ +import { SceneControls } from '../ui/scenes.js'; import * as Cesium from 'cesium'; import { SCENE_RECIPES } from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; @@ -21,8 +22,6 @@ import { decodeBloomIntensity, } from '../bloom.js'; -/** @constant {string} Key code used to abort a running scene */ -const ESCAPE_KEY = 'Escape'; /** @constant {string} localStorage key for the serialized project */ const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; /** @constant {number} Current schema version for project migration */ @@ -32,15 +31,6 @@ const DEFAULT_SHOT_DURATION_SEC = 4; /** @constant {number} Default hold/pause after a shot completes (seconds) */ const DEFAULT_HOLD_SEC = 0.9; -/** - * Clamp a numeric value to the [0, 1] range. - * @param {number} value - * @returns {number} - */ -function clamp01(value) { - return Math.max(0, Math.min(1, value)); -} - /** * Generate a short random identifier with the given prefix. * @param {string} prefix - e.g. 'shot', 'scene' @@ -298,7 +288,6 @@ export class SceneDirector { constructor(viewer, styleManager, dataManager) { this._destroyed = false; this._pendingWork = new Set(); - this._uiRemovers = []; this.viewer = viewer; this.styleManager = styleManager; this.dataManager = dataManager; @@ -321,39 +310,14 @@ export class SceneDirector { this._lastRun = null; /** @type {string} JSON string of _lastRun for download */ this._lastRunJson = ''; - this._onKeyDown = this._onKeyDown.bind(this); this._project = this._loadProject(); this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; - // Cache DOM element references for the scene panel UI - this._scenePanel = document.getElementById('scene-panel'); - this._sceneSelect = document.getElementById('scene-select'); - this._sceneNewBtn = document.getElementById('scene-new-btn'); - this._sceneDeleteBtn = document.getElementById('scene-delete-btn'); - this._sceneCaptureBtn = document.getElementById('scene-capture-btn'); - this._sceneUpdateShotBtn = document.getElementById('scene-update-shot-btn'); - this._sceneShotList = document.getElementById('scene-shot-list'); - this._sceneStartBtn = document.getElementById('scene-start-btn'); - this._sceneStopBtn = document.getElementById('scene-stop-btn'); - this._sceneNextBtn = document.getElementById('scene-next-btn'); - this._sceneExportBtn = document.getElementById('scene-export-btn'); - this._sceneImportBtn = document.getElementById('scene-import-btn'); - this._sceneImportFile = document.getElementById('scene-import-file'); - this._sceneDownloadBtn = document.getElementById('scene-download-btn'); - this._sceneStatus = document.getElementById('scene-status'); - this._sceneProgressFill = document.getElementById('scene-progress-fill'); - this._sceneRuntime = document.getElementById('scene-runtime'); - this._initUI(); } - _listen(target, type, listener) { - if (!target) return; - target.addEventListener(type, listener); - this._uiRemovers.push(() => target.removeEventListener(type, listener)); - } _trackWork(promise) { this._pendingWork ||= new Set(); @@ -367,18 +331,15 @@ export class SceneDirector { destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; + this._controls?.destroy(); this._destroyPromise = Promise.resolve().then(async () => { this.stopScene('Stopped'); this._loadAbort?.abort(); this._loadGeneration++; this.viewer.camera.cancelFlight(); - for (const remove of this._uiRemovers || []) remove(); - this._uiRemovers = []; - if (this._sceneShotList) this._sceneShotList.textContent = ''; clearTimeout(this._storageToastTimer); await Promise.allSettled(this._pendingWork || []); clearInterval(this._progressTimer); - document.removeEventListener('keydown', this._onKeyDown); }); return this._destroyPromise; } @@ -434,79 +395,50 @@ export class SceneDirector { * Exits silently if the scene-select element is missing (headless/test mode). */ _initUI() { - if (!this._sceneSelect) return; - - this._renderSceneSelect(); - this._renderShotList(); - - this._listen(this._sceneSelect, 'change', () => { - this._selectedSceneId = this._sceneSelect.value; - const scene = this._getSelectedScene(); - this._selectedShotId = scene?.shots[0]?.id || null; - this._renderShotList(); + this._controls = new SceneControls({ + read: () => ({ + scenes: this._project.scenes, + selectedSceneId: this._selectedSceneId, + selectedShotId: this._selectedShotId, + running: this._running, + hasRun: !!this._lastRunJson, + }), + actions: { + selectScene: (id) => { + this._selectedSceneId = id; + this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null; + this._renderShotList(); + }, + selectShot: (id) => { this._selectedShotId = id; this._renderShotList(); }, + renameShot: (sceneId, shotId, title) => { + const { shot } = this._getShot(sceneId, shotId); + if (!shot) return; + shot.title = title.trim() || shot.title; + this._saveProject(); + this._renderShotList(); + }, + create: () => this._createScene(), + deleteScene: () => this._deleteSelectedScene(), + capture: () => this.captureShot(), + update: () => this.updateSelectedShot(), + start: (id) => this.startScene(id), + stop: (reason) => this.stopScene(reason), + next: () => this.runNextScene(), + export: () => this.exportProject(), + import: (file) => this.importProjectFile(file), + download: () => this.downloadLastRunMetadata(), + load: (sceneId, shotId) => this.loadShot(sceneId, shotId), + deleteShot: (sceneId, shotId) => this.deleteShot(sceneId, shotId), + }, }); - - this._listen(this._sceneNewBtn, 'click', () => this._createScene()); - this._listen(this._sceneDeleteBtn, 'click', () => this._deleteSelectedScene()); - this._listen(this._sceneCaptureBtn, 'click', () => this.captureShot()); - this._listen(this._sceneUpdateShotBtn, 'click', () => this.updateSelectedShot()); - - this._listen(this._sceneStartBtn, 'click', () => { - this.startScene(this._selectedSceneId); - }); - - this._listen(this._sceneStopBtn, 'click', () => { - this.stopScene('Stopped'); - }); - - this._listen(this._sceneNextBtn, 'click', () => { - this.runNextScene(); - }); - - this._listen(this._sceneExportBtn, 'click', () => { - this.exportProject(); - }); - - this._listen(this._sceneImportBtn, 'click', () => { - this._sceneImportFile?.click(); - }); - - this._listen(this._sceneImportFile, 'change', async () => { - const file = this._sceneImportFile?.files?.[0]; - if (!file) return; - await this.importProjectFile(file); - this._sceneImportFile.value = ''; - }); - - this._listen(this._sceneDownloadBtn, 'click', () => { - this.downloadLastRunMetadata(); - }); - - this._updateStatus('Ready'); - this._setProgress(0); - this._setButtons(false); } /** Rebuild the scene dropdown options and sync the selected value. */ _renderSceneSelect() { - if (!this._sceneSelect) return; - - this._sceneSelect.innerHTML = ''; - for (const scene of this._project.scenes) { - const option = document.createElement('option'); - option.value = scene.id; - option.textContent = scene.title; - this._sceneSelect.appendChild(option); - } - - // Reset selection if the previously selected scene no longer exists if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) { this._selectedSceneId = this._project.scenes[0]?.id || null; } - - if (this._selectedSceneId) { - this._sceneSelect.value = this._selectedSceneId; - } + this._controls?.renderSceneSelect(); } /** @@ -515,79 +447,11 @@ export class SceneDirector { * LOAD/DEL action buttons. Supports click-to-select and double-click rename. */ _renderShotList() { - if (!this._sceneShotList) return; - const scene = this._getSelectedScene(); - this._sceneShotList.innerHTML = ''; - - if (!scene || scene.shots.length === 0) { - const empty = document.createElement('div'); - empty.className = 'scene-shot-empty'; - empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.'; - this._sceneShotList.appendChild(empty); - return; - } - - // Auto-select first shot if current selection is stale - if (!scene.shots.some((shot) => shot.id === this._selectedShotId)) { + if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) { this._selectedShotId = scene.shots[0].id; } - - for (const shot of scene.shots) { - const row = document.createElement('div'); - row.className = 'scene-shot-row'; - row.classList.toggle('active', shot.id === this._selectedShotId); - - const top = document.createElement('div'); - top.className = 'scene-shot-top'; - - const label = document.createElement('div'); - label.className = 'scene-shot-label'; - label.textContent = shot.title; - label.addEventListener('click', () => { - this._selectedShotId = shot.id; - this._renderShotList(); - }); - label.addEventListener('dblclick', () => { - const nextTitle = window.prompt('Shot title', shot.title); - if (!nextTitle) return; - shot.title = nextTitle.trim() || shot.title; - this._saveProject(); - this._renderShotList(); - }); - - const actions = document.createElement('div'); - actions.className = 'scene-shot-actions'; - - const loadBtn = document.createElement('button'); - loadBtn.className = 'scene-shot-btn'; - loadBtn.textContent = 'LOAD'; - loadBtn.addEventListener('click', () => { - this.loadShot(scene.id, shot.id); - }); - - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'scene-shot-btn scene-shot-danger'; - deleteBtn.textContent = 'DEL'; - deleteBtn.addEventListener('click', () => { - this.deleteShot(scene.id, shot.id); - }); - - actions.appendChild(loadBtn); - actions.appendChild(deleteBtn); - top.appendChild(label); - top.appendChild(actions); - - const meta = document.createElement('div'); - meta.className = 'scene-shot-meta'; - const mode = shot.visual?.detection?.mode || 'OFF'; - const style = shot.visual?.style || 'normal'; - meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`; - - row.appendChild(top); - row.appendChild(meta); - this._sceneShotList.appendChild(row); - } + this._controls?.renderShotList(); } /** @@ -949,7 +813,7 @@ export class SceneDirector { // Transition to running state this._running = true; - document.body.classList.add('scene-playback-mode'); + this._controls?.setPlaybackActive(true); this._setButtons(true); this._setProgress(0); @@ -984,7 +848,7 @@ export class SceneDirector { this._startProgressTicker(estimatedDurationSec || 1); this._logEvent('scene_run_start', { count: queue.length }); - document.addEventListener('keydown', this._onKeyDown); + this._controls?.setPlaybackKeyboardEnabled(true); try { // Main shot sequencing loop @@ -1340,14 +1204,14 @@ export class SceneDirector { _finishRun() { clearInterval(this._progressTimer); this._progressTimer = null; - document.removeEventListener('keydown', this._onKeyDown); + this._controls?.setPlaybackKeyboardEnabled(false); // Covers the error path too: a run that threw mid-shot must not leave a // layer transition running against a director that has stopped watching. this._runAbort?.abort(); this._runAbort = null; this.styleManager.setRecordingMode(false); - document.body.classList.remove('scene-playback-mode'); + this._controls?.setPlaybackActive(false); this._updateRuntime(''); this._running = false; @@ -1369,51 +1233,25 @@ export class SceneDirector { * Editing controls are disabled during a run; stop is disabled when idle. * @param {boolean} isRunning */ - _setButtons(isRunning) { - if (this._sceneStartBtn) this._sceneStartBtn.disabled = isRunning; - if (this._sceneNextBtn) this._sceneNextBtn.disabled = isRunning; - if (this._sceneSelect) this._sceneSelect.disabled = isRunning; - - if (this._sceneNewBtn) this._sceneNewBtn.disabled = isRunning; - if (this._sceneDeleteBtn) this._sceneDeleteBtn.disabled = isRunning; - if (this._sceneCaptureBtn) this._sceneCaptureBtn.disabled = isRunning; - if (this._sceneUpdateShotBtn) this._sceneUpdateShotBtn.disabled = isRunning; - if (this._sceneExportBtn) this._sceneExportBtn.disabled = isRunning; - if (this._sceneImportBtn) this._sceneImportBtn.disabled = isRunning; - - if (this._sceneStopBtn) this._sceneStopBtn.disabled = !isRunning; - if (this._sceneDownloadBtn) this._sceneDownloadBtn.disabled = !this._lastRunJson; - if (this._scenePanel) this._scenePanel.classList.toggle('running', isRunning); - } + _setButtons(isRunning) { this._controls?.setButtons(isRunning); } /** * Update the progress bar fill width and label. * @param {number} progress - Value in [0, 1] */ - _setProgress(progress) { - if (!this._sceneProgressFill) return; - const pct = Math.round(clamp01(progress) * 100); - this._sceneProgressFill.style.width = `${pct}%`; - this._sceneProgressFill.textContent = `${pct}%`; - } + _setProgress(progress) { this._controls?.setProgress(progress); } /** * Set the status line text in the scene panel. * @param {string} text */ - _updateStatus(text) { - if (this._sceneStatus) this._sceneStatus.textContent = text; - } + _updateStatus(text) { this._controls?.updateStatus(text); } /** * Set the runtime label (scene/shot name) and toggle its active class. * @param {string} text - Empty string hides the label */ - _updateRuntime(text) { - if (!this._sceneRuntime) return; - this._sceneRuntime.textContent = text; - this._sceneRuntime.classList.toggle('active', !!text); - } + _updateRuntime(text) { this._controls?.updateRuntime(text); } /** * Append a timestamped telemetry event to the active run log. @@ -1429,13 +1267,4 @@ export class SceneDirector { }); } - /** - * Global keydown handler registered during a run. Escape cancels the run. - * @param {KeyboardEvent} event - */ - _onKeyDown(event) { - if (event.key === ESCAPE_KEY && this._running) { - this.stopScene('Stopped (Esc)'); - } - } } diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js new file mode 100644 index 000000000..26e032258 --- /dev/null +++ b/src/ui/sceneControls.js @@ -0,0 +1,116 @@ +/** Own Scene panel listeners and presentation through project reads and actions. */ +import { sceneElements, renderSceneOptions, renderSceneShots, presentSceneButtons, presentSceneProgress, presentSceneRuntime } from './scenePresentation.js'; + +export class SceneControls { + constructor({ read, actions, elements = sceneElements() }) { + this.read = read; + this.actions = actions; + this.elements = elements; + this.destroyed = false; + this.actionGeneration = 0; + this.removers = []; + this.rowRemovers = []; + this.playbackKeyRemover = null; + if (!elements.select) return; + this.renderSceneSelect(); + this.renderShotList(); + this.listen(elements.select, 'change', () => this.run('selectScene', elements.select.value)); + for (const [element, action] of Object.entries({ new: 'create', delete: 'deleteScene', capture: 'capture', update: 'update', next: 'next', export: 'export', download: 'download' })) { + this.listen(elements[element], 'click', () => this.run(action)); + } + this.listen(elements.start, 'click', () => this.run('start', this.read().selectedSceneId)); + this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped')); + this.listen(elements.import, 'click', () => elements.file?.click()); + this.listen(elements.file, 'change', async () => { + const file = elements.file?.files?.[0]; + if (!file) return; + await this.run('import', file); + if (!this.destroyed) elements.file.value = ''; + }); + this.updateStatus('Ready'); + this.setProgress(0); + this.setButtons(false); + } + + listen(target, type, callback, removers = this.removers) { + if (!target) return; + const listener = (event) => { if (!this.destroyed) return callback(event); }; + target.addEventListener(type, listener); + removers.push(() => target.removeEventListener(type, listener)); + } + + run(action, ...args) { + if (this.destroyed) return; + const generation = ++this.actionGeneration; + const failed = () => { + if (!this.destroyed && generation === this.actionGeneration) this.updateStatus('Scene action failed'); + }; + try { + const result = this.actions[action](...args); + return result?.then ? Promise.resolve(result).catch(failed) : result; + } catch { + failed(); + } + } + + renderSceneSelect() { + if (!this.destroyed) renderSceneOptions(this.elements.select, this.read()); + } + + renderShotList() { + if (this.destroyed) return; + for (const remove of this.rowRemovers.splice(0)) remove(); + renderSceneShots(this.elements.shots, this.read(), { + listen: (element, type, callback) => this.listen(element, type, callback, this.rowRemovers), + select: (id) => this.run('selectShot', id), + rename: (sceneId, shotId, title) => this.run('renameShot', sceneId, shotId, title), + load: (sceneId, shotId) => this.run('load', sceneId, shotId), + remove: (sceneId, shotId) => this.run('deleteShot', sceneId, shotId), + }); + } + + setButtons(running) { + if (!this.destroyed) presentSceneButtons(this.elements, running, this.read().hasRun); + } + + setProgress(progress) { + if (!this.destroyed) presentSceneProgress(this.elements.progress, progress); + } + + updateStatus(text) { + if (!this.destroyed && this.elements.status) this.elements.status.textContent = text; + } + + updateRuntime(text) { + if (!this.destroyed) presentSceneRuntime(this.elements.runtime, text); + } + + setPlaybackActive(active) { + if (this.destroyed && active) return; + document.body.classList.toggle('scene-playback-mode', active); + } + + setPlaybackKeyboardEnabled(enabled) { + if (!enabled) { + this.playbackKeyRemover?.(); + this.playbackKeyRemover = null; + } else if (!this.destroyed && !this.playbackKeyRemover) { + const onKeyDown = (event) => { + if (!this.destroyed && event.key === 'Escape' && this.read().running) this.run('stop', 'Stopped (Esc)'); + }; + document.addEventListener('keydown', onKeyDown); + this.playbackKeyRemover = () => document.removeEventListener('keydown', onKeyDown); + } + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.actionGeneration++; + this.setPlaybackKeyboardEnabled(false); + for (const remove of [...this.removers.splice(0), ...this.rowRemovers.splice(0)]) remove(); + if (this.elements.shots) this.elements.shots.textContent = ''; + presentSceneRuntime(this.elements.runtime, ''); + this.setPlaybackActive(false); + } +} diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs new file mode 100644 index 000000000..512ffb8a6 --- /dev/null +++ b/src/ui/sceneControls.test.mjs @@ -0,0 +1,145 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SceneControls } from './sceneControls.js'; + +function fixture() { + class Element extends EventTarget { + constructor() { + super(); + this.children = []; + this.style = {}; + this.value = ''; + this._text = ''; + const classes = new Set(); + this.classList = { + toggle(name, active) { if (active) classes.add(name); else classes.delete(name); }, + contains: (name) => classes.has(name), + }; + } + appendChild(element) { this.children.push(element); } + set textContent(value) { this._text = value; this.children = []; } + get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); } + click() { this.dispatchEvent(new Event('click')); } + } + const saved = { document: globalThis.document, window: globalThis.window }; + const document = Object.assign(new EventTarget(), { createElement: () => new Element(), body: new Element() }); + globalThis.document = document; + globalThis.window = { prompt: () => 'Renamed' }; + const elements = Object.fromEntries(['panel','select','new','delete','capture','update','shots','start','stop','next','export','import','file','download','status','progress','runtime'].map((name) => [name, new Element()])); + const state = { + scenes: [{ id: 'scene-a', title: '', shots: [{ id: 'shot-a', title: '', durationSec: 4, holdSec: 0.9, visual: { style: 'normal' } }] }], + selectedSceneId: 'scene-a', selectedShotId: 'shot-a', running: false, hasRun: false, + }; + const calls = []; + const actions = Object.fromEntries(['selectScene','selectShot','renameShot','create','deleteScene','capture','update','start','stop','next','export','import','download','load','deleteShot'].map((name) => [name, (...args) => { calls.push([name, ...args]); }])); + const owner = new SceneControls({ read: () => state, actions, elements }); + return { owner, state, actions, elements, calls, document, restore() { owner.destroy(); Object.assign(globalThis, saved); } }; +} + +test('Scene controls render the supplied selection and running/download states', () => { + const f = fixture(); + try { + assert.equal(f.elements.select.children[0].textContent, ''); + assert.equal(f.elements.select.value, 'scene-a'); + assert.equal(f.elements.shots.children[0].children[0].children[0].textContent, ''); + assert.equal(f.elements.stop.disabled, true); + assert.equal(f.elements.download.disabled, true); + f.state.hasRun = true; + f.owner.setButtons(true); + assert.equal(f.elements.start.disabled, true); + assert.equal(f.elements.capture.disabled, true); + assert.equal(f.elements.stop.disabled, false); + assert.equal(f.elements.download.disabled, false); + assert.equal(f.elements.panel.classList.contains('running'), true); + f.owner.setProgress(2); + assert.equal(f.elements.progress.style.width, '100%'); + f.owner.setProgress(-1); + assert.equal(f.elements.progress.textContent, '0%'); + } finally { f.restore(); } +}); + +test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => { + const f = fixture(); + try { + const row = f.elements.shots.children[0]; + const label = row.children[0].children[0]; + const load = row.children[0].children[1].children[0]; + f.elements.capture.click(); + f.elements.start.click(); + label.click(); + label.dispatchEvent(new Event('dblclick')); + load.click(); + assert.deepEqual(f.calls, [['capture'], ['start','scene-a'], ['selectShot','shot-a'], ['renameShot','scene-a','shot-a','Renamed'], ['load','scene-a','shot-a']]); + f.owner.renderShotList(); + label.click(); + load.click(); + assert.equal(f.calls.length, 5); + f.elements.shots.children[0].children[0].children[1].children[0].click(); + assert.equal(f.calls.length, 6); + } finally { f.restore(); } +}); + +test('Scene Escape and recording presentation follow playback and release on destruction', () => { + const f = fixture(); + const escape = () => f.document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' })); + try { + escape(); + assert.deepEqual(f.calls, []); + f.state.running = true; + f.owner.setPlaybackActive(true); + f.owner.setPlaybackKeyboardEnabled(true); + f.owner.updateRuntime('Scene A / Shot A'); + escape(); + assert.deepEqual(f.calls, [['stop', 'Stopped (Esc)']]); + f.owner.destroy(); + f.owner.destroy(); + f.owner.setPlaybackKeyboardEnabled(true); + escape(); + f.elements.capture.click(); + f.owner.run('capture'); + assert.equal(f.calls.length, 1); + assert.equal(f.document.body.classList.contains('scene-playback-mode'), false); + assert.equal(f.elements.runtime.textContent, ''); + assert.equal(f.owner.removers.length, 0); + assert.equal(f.owner.rowRemovers.length, 0); + } finally { f.restore(); } +}); + +test('a file import finishing after disposal cannot clear the retained file control', async () => { + const f = fixture(); + try { + let finish; + f.actions.import = () => new Promise((resolve) => { finish = resolve; }); + f.elements.file.files = [{ name: 'project.json' }]; + f.elements.file.value = 'project.json'; + f.elements.file.dispatchEvent(new Event('change')); + f.owner.destroy(); + finish(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(f.elements.file.value, 'project.json'); + assert.equal(f.elements.status.textContent, 'Ready'); + } finally { f.restore(); } +}); + +test('Scene action failures are visible only while that action still owns presentation', async () => { + const f = fixture(); + try { + let reject; + f.actions.load = () => new Promise((_resolve, fail) => { reject = fail; }); + let pending = f.owner.run('load', 'scene-a', 'shot-a'); + reject(new Error('unavailable')); + await pending; + assert.equal(f.elements.status.textContent, 'Scene action failed'); + pending = f.owner.run('load', 'scene-a', 'shot-a'); + f.owner.run('selectShot', 'shot-a'); + f.owner.updateStatus('Current selection'); + reject(new Error('obsolete')); + await pending; + assert.equal(f.elements.status.textContent, 'Current selection'); + pending = f.owner.run('load', 'scene-a', 'shot-a'); + f.owner.destroy(); + reject(new Error('disposed')); + await pending; + assert.equal(f.elements.status.textContent, 'Current selection'); + } finally { f.restore(); } +}); diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js new file mode 100644 index 000000000..a4a449a08 --- /dev/null +++ b/src/ui/scenePresentation.js @@ -0,0 +1,96 @@ +/** Scene panel elements and text-only project presentation. */ +export function sceneElements(root = document) { + const ids = { + panel: 'scene-panel', select: 'scene-select', new: 'scene-new-btn', + delete: 'scene-delete-btn', capture: 'scene-capture-btn', update: 'scene-update-shot-btn', + shots: 'scene-shot-list', start: 'scene-start-btn', stop: 'scene-stop-btn', + next: 'scene-next-btn', export: 'scene-export-btn', import: 'scene-import-btn', + file: 'scene-import-file', download: 'scene-download-btn', status: 'scene-status', + progress: 'scene-progress-fill', runtime: 'scene-runtime', + }; + return Object.fromEntries(Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)])); +} + +export function renderSceneOptions(element, { scenes, selectedSceneId }) { + if (!element) return; + element.textContent = ''; + for (const scene of scenes) { + const option = document.createElement('option'); + option.value = scene.id; + option.textContent = scene.title; + element.appendChild(option); + } + if (selectedSceneId) element.value = selectedSceneId; +} + +export function renderSceneShots(element, state, { listen, select, rename, load, remove }) { + if (!element) return; + const scene = state.scenes.find((item) => item.id === state.selectedSceneId); + element.textContent = ''; + if (!scene || scene.shots.length === 0) { + const empty = document.createElement('div'); + empty.className = 'scene-shot-empty'; + empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.'; + element.appendChild(empty); + return; + } + for (const shot of scene.shots) { + const row = document.createElement('div'); + row.className = 'scene-shot-row'; + row.classList.toggle('active', shot.id === state.selectedShotId); + const top = document.createElement('div'); + top.className = 'scene-shot-top'; + const label = document.createElement('div'); + label.className = 'scene-shot-label'; + label.textContent = shot.title; + listen(label, 'click', () => select(shot.id)); + listen(label, 'dblclick', () => { + const title = window.prompt('Shot title', shot.title); + if (title) rename(scene.id, shot.id, title); + }); + const actions = document.createElement('div'); + actions.className = 'scene-shot-actions'; + const loadButton = document.createElement('button'); + loadButton.className = 'scene-shot-btn'; + loadButton.textContent = 'LOAD'; + listen(loadButton, 'click', () => load(scene.id, shot.id)); + const deleteButton = document.createElement('button'); + deleteButton.className = 'scene-shot-btn scene-shot-danger'; + deleteButton.textContent = 'DEL'; + listen(deleteButton, 'click', () => remove(scene.id, shot.id)); + actions.appendChild(loadButton); + actions.appendChild(deleteButton); + top.appendChild(label); + top.appendChild(actions); + const meta = document.createElement('div'); + meta.className = 'scene-shot-meta'; + const mode = shot.visual?.detection?.mode || 'OFF'; + const style = shot.visual?.style || 'normal'; + meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`; + row.appendChild(top); + row.appendChild(meta); + element.appendChild(row); + } +} + +export function presentSceneButtons(elements, running, hasRun) { + for (const name of ['start', 'next', 'select', 'new', 'delete', 'capture', 'update', 'export', 'import']) { + if (elements[name]) elements[name].disabled = running; + } + if (elements.stop) elements.stop.disabled = !running; + if (elements.download) elements.download.disabled = !hasRun; + elements.panel?.classList.toggle('running', running); +} + +export function presentSceneProgress(element, progress) { + if (!element) return; + const percentage = Math.round(Math.max(0, Math.min(1, progress)) * 100); + element.style.width = `${percentage}%`; + element.textContent = `${percentage}%`; +} + +export function presentSceneRuntime(element, text) { + if (!element) return; + element.textContent = text; + element.classList.toggle('active', !!text); +} diff --git a/src/ui/scenes.js b/src/ui/scenes.js new file mode 100644 index 000000000..57ac18fb4 --- /dev/null +++ b/src/ui/scenes.js @@ -0,0 +1 @@ +export { SceneControls } from './sceneControls.js'; From d42036d74fccba2ca80b99e0957f8328ac403ced Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 03:30:05 -0700 Subject: [PATCH 50/92] style: format Scene controls and browser acceptance --- scripts/format-scope.json | 7 +- scripts/qa-scene-controls.mjs | 328 ++++++++++++++++++++++++++++------ src/ui/sceneControls.js | 58 ++++-- src/ui/sceneControls.test.mjs | 163 ++++++++++++++--- src/ui/scenePresentation.js | 45 ++++- 5 files changed, 498 insertions(+), 103 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 29b3d5138..e375bee6d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -208,5 +208,10 @@ "src/ui/cockpitTrackingController.js", "src/cockpitMath.js", "src/cockpitUtilityLayout.js", - "src/cockpitVisionPolicy.js" + "src/cockpitVisionPolicy.js", + "src/ui/scenes.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", + "src/ui/sceneControls.test.mjs", + "scripts/qa-scene-controls.mjs" ] diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index af124f04e..f6606de27 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -6,123 +6,335 @@ import puppeteer from 'puppeteer'; const shots = path.resolve('qa-shots/scene-controls'); fs.mkdirSync(shots, { recursive: true }); -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 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 promptValue = 'QA Scene'; -page.on('dialog', async (dialog) => { await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined); }); +page.on('dialog', async (dialog) => { + await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined); +}); let failures = 0; -function check(name, ok) { console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); if (!ok) failures++; } +function check(name, ok) { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +} try { await page.setViewport({ width: 1440, height: 900 }); - await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => window.__godsEyeView?.sceneDirector?._controls && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector?._controls && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); await page.click('[data-collapse-target="scene-panel"]'); await page.click('#scene-new-btn'); - await page.waitForFunction(() => document.querySelector('#scene-select option:checked')?.textContent === 'QA Scene'); - check('New creates and selects a persisted empty scene', await page.evaluate(() => { - const director = window.__godsEyeView.sceneDirector; - const saved = JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')); - return director._getSelectedScene().shots.length === 0 && !!document.querySelector('.scene-shot-empty') && saved.scenes.some((scene) => scene.title === 'QA Scene'); - })); + await page.waitForFunction( + () => + document.querySelector('#scene-select option:checked')?.textContent === + 'QA Scene', + ); + check( + 'New creates and selects a persisted empty scene', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const saved = JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + return ( + director._getSelectedScene().shots.length === 0 && + !!document.querySelector('.scene-shot-empty') && + saved.scenes.some((scene) => scene.title === 'QA Scene') + ); + }), + ); await page.click('#scene-capture-btn'); - await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 1); - check('Capture stores the actual camera and visual state', await page.evaluate(() => { - const director = window.__godsEyeView.sceneDirector; - const shot = director._getSelectedScene().shots[0]; - const current = director.styleManager.getCameraState(); - return Number.isFinite(shot.camera.lat) && Math.abs(shot.camera.lat - current.lat) < 0.01 && shot.visual.style === director.styleManager.getVisualState().style; - })); + await page.waitForFunction( + () => document.querySelectorAll('.scene-shot-row').length === 1, + ); + check( + 'Capture stores the actual camera and visual state', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const shot = director._getSelectedScene().shots[0]; + const current = director.styleManager.getCameraState(); + return ( + Number.isFinite(shot.camera.lat) && + Math.abs(shot.camera.lat - current.lat) < 0.01 && + shot.visual.style === director.styleManager.getVisualState().style + ); + }), + ); promptValue = 'QA shot'; await page.click('.scene-shot-label', { clickCount: 2 }); - await page.waitForFunction(() => document.querySelector('.scene-shot-label')?.textContent === 'QA shot'); - check('Rename persists literal text without interpreting markup', await page.evaluate(() => !document.querySelector('.scene-shot-label b') && JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes.some((scene) => scene.shots.some((shot) => shot.title === 'QA shot')))); + await page.waitForFunction( + () => + document.querySelector('.scene-shot-label')?.textContent === + 'QA shot', + ); + check( + 'Rename persists literal text without interpreting markup', + await page.evaluate( + () => + !document.querySelector('.scene-shot-label b') && + JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ).scenes.some((scene) => + scene.shots.some((shot) => shot.title === 'QA shot'), + ), + ), + ); await page.click('#scene-capture-btn'); - await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 2); + await page.waitForFunction( + () => document.querySelectorAll('.scene-shot-row').length === 2, + ); await page.click('.scene-shot-label'); - check('Shot selection and Update use the selected shot', await page.evaluate(() => { - const director = window.__godsEyeView.sceneDirector; - const scene = director._getSelectedScene(); - const count = scene.shots.length; - document.getElementById('scene-update-shot-btn').click(); - return director._selectedShotId === scene.shots[0].id && scene.shots.length === count && document.querySelector('.scene-shot-row.active .scene-shot-label').textContent === scene.shots[0].title; - })); + check( + 'Shot selection and Update use the selected shot', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const scene = director._getSelectedScene(); + const count = scene.shots.length; + document.getElementById('scene-update-shot-btn').click(); + return ( + director._selectedShotId === scene.shots[0].id && + scene.shots.length === count && + document.querySelector('.scene-shot-row.active .scene-shot-label') + .textContent === scene.shots[0].title + ); + }), + ); await page.evaluate(() => { window.__sceneExports = []; const create = URL.createObjectURL.bind(URL); URL.createObjectURL = (blob) => { - if (blob.type === 'application/json') window.__sceneExports.push(blob.text()); + if (blob.type === 'application/json') + window.__sceneExports.push(blob.text()); return create(blob); }; }); await page.click('#scene-export-btn'); - const exported = await page.evaluate(async () => JSON.parse(await window.__sceneExports[0])); - check('Export produces the current project JSON', exported.scenes.some((scene) => scene.title === 'QA Scene' && scene.shots.length === 2)); + const exported = await page.evaluate(async () => + JSON.parse(await window.__sceneExports[0]), + ); + check( + 'Export produces the current project JSON', + exported.scenes.some( + (scene) => scene.title === 'QA Scene' && scene.shots.length === 2, + ), + ); // Keep real camera/style playback while isolating this control check from // live provider availability. Layer transactions are covered by their suites. const scene = exported.scenes.find((item) => item.title === 'QA Scene'); - scene.shots = scene.shots.slice(0, 1).map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} })); + scene.shots = scene.shots + .slice(0, 1) + .map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} })); const fixture = { ...exported, scenes: [scene] }; const projectFile = path.join(shots, 'project.json'); fs.writeFileSync(projectFile, JSON.stringify(fixture)); const input = await page.$('#scene-import-file'); await input.uploadFile(projectFile); - await page.waitForFunction(() => document.getElementById('scene-status').textContent === 'Imported project.json'); - check('Import replaces the project and clears the file input', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && document.querySelectorAll('.scene-shot-row').length === 1 && document.getElementById('scene-import-file').value === '')); + await page.waitForFunction( + () => + document.getElementById('scene-status').textContent === + 'Imported project.json', + ); + check( + 'Import replaces the project and clears the file input', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length === 1 && + document.querySelectorAll('.scene-shot-row').length === 1 && + document.getElementById('scene-import-file').value === '', + ), + ); await page.click('.scene-shot-btn'); - await page.waitForFunction(() => document.getElementById('scene-status').textContent.startsWith('Loaded:'), { timeout: 30000 }); - check('LOAD completes the real visual and camera operation', await page.evaluate(() => !window.__godsEyeView.sceneDirector.running && document.getElementById('scene-status').textContent.includes('QA shot'))); + await page.waitForFunction( + () => + document.getElementById('scene-status').textContent.startsWith('Loaded:'), + { timeout: 30000 }, + ); + check( + 'LOAD completes the real visual and camera operation', + await page.evaluate( + () => + !window.__godsEyeView.sceneDirector.running && + document + .getElementById('scene-status') + .textContent.includes('QA shot'), + ), + ); await page.click('#scene-start-btn'); - await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running && document.body.classList.contains('scene-playback-mode')); - check('Playback enters recording presentation and disables editing', await page.evaluate(() => document.getElementById('scene-capture-btn').disabled && document.getElementById('scene-start-btn').disabled && !document.getElementById('scene-stop-btn').disabled && window.__godsEyeView.styleManager.getViewState().recording)); + await page.waitForFunction( + () => + window.__godsEyeView.sceneDirector.running && + document.body.classList.contains('scene-playback-mode'), + ); + check( + 'Playback enters recording presentation and disables editing', + await page.evaluate( + () => + document.getElementById('scene-capture-btn').disabled && + document.getElementById('scene-start-btn').disabled && + !document.getElementById('scene-stop-btn').disabled && + window.__godsEyeView.styleManager.getViewState().recording, + ), + ); await page.keyboard.press('Escape'); - await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running && !document.body.classList.contains('scene-playback-mode')); - check('Escape ends playback and restores idle/recording controls', await page.evaluate(() => !document.getElementById('scene-start-btn').disabled && document.getElementById('scene-stop-btn').disabled && !document.getElementById('scene-download-btn').disabled && !window.__godsEyeView.styleManager.getViewState().recording)); + await page.waitForFunction( + () => + !window.__godsEyeView.sceneDirector.running && + !document.body.classList.contains('scene-playback-mode'), + ); + check( + 'Escape ends playback and restores idle/recording controls', + await page.evaluate( + () => + !document.getElementById('scene-start-btn').disabled && + document.getElementById('scene-stop-btn').disabled && + !document.getElementById('scene-download-btn').disabled && + !window.__godsEyeView.styleManager.getViewState().recording, + ), + ); await page.click('#scene-download-btn'); - const metadata = await page.evaluate(async () => JSON.parse(await window.__sceneExports.at(-1))); - check('Run download contains the completed cancelled run', Boolean(metadata.endedAt && metadata.wasCancelled && Array.isArray(metadata.events))); + const metadata = await page.evaluate(async () => + JSON.parse(await window.__sceneExports.at(-1)), + ); + check( + 'Run download contains the completed cancelled run', + Boolean( + metadata.endedAt && + metadata.wasCancelled && + Array.isArray(metadata.events), + ), + ); await page.click('#scene-start-btn'); await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running); await page.$eval('#scene-stop-btn', (button) => button.click()); await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running); - check('The installed Stop control also completes playback cleanup', await page.evaluate(() => !document.body.classList.contains('scene-playback-mode') && !document.getElementById('scene-start-btn').disabled)); + check( + 'The installed Stop control also completes playback cleanup', + await page.evaluate( + () => + !document.body.classList.contains('scene-playback-mode') && + !document.getElementById('scene-start-btn').disabled, + ), + ); const badFile = path.join(shots, 'invalid.json'); fs.writeFileSync(badFile, '{invalid'); await input.uploadFile(badFile); - await page.waitForFunction(() => document.getElementById('scene-status').textContent.includes('invalid JSON')); - check('Invalid import reports failure and preserves the current project', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && window.__godsEyeView.sceneDirector._getSelectedScene().title === 'QA Scene')); + await page.waitForFunction(() => + document + .getElementById('scene-status') + .textContent.includes('invalid JSON'), + ); + check( + 'Invalid import reports failure and preserves the current project', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length === 1 && + window.__godsEyeView.sceneDirector._getSelectedScene().title === + 'QA Scene', + ), + ); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); await page.screenshot({ path: path.join(shots, 'narrow.png') }); - check('Scene controls stay inside the narrow viewport', await page.$eval('#scene-select', (element) => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth; })); + check( + 'Scene controls stay inside the narrow viewport', + await page.$eval('#scene-select', (element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth; + }), + ); await page.setViewport({ width: 1440, height: 900 }); await page.click('.scene-shot-danger'); - await page.waitForFunction(() => !!document.querySelector('.scene-shot-empty')); - check('Delete shot leaves the empty-state presentation', await page.evaluate(() => window.__godsEyeView.sceneDirector._getSelectedScene().shots.length === 0)); + await page.waitForFunction( + () => !!document.querySelector('.scene-shot-empty'), + ); + check( + 'Delete shot leaves the empty-state presentation', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._getSelectedScene().shots.length === + 0, + ), + ); await page.click('#scene-delete-btn'); - await page.waitForFunction(() => !window.__godsEyeView.sceneDirector._project.scenes.some((item) => item.title === 'QA Scene')); - check('Deleting the final scene restores the built-in recipes', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length > 0 && document.querySelectorAll('#scene-select option').length > 0)); + await page.waitForFunction( + () => + !window.__godsEyeView.sceneDirector._project.scenes.some( + (item) => item.title === 'QA Scene', + ), + ); + check( + 'Deleting the final scene restores the built-in recipes', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length > 0 && + document.querySelectorAll('#scene-select option').length > 0, + ), + ); const teardown = await page.evaluate(async () => { const director = window.__godsEyeView.sceneDirector; const controls = director._controls; const previousStatus = document.getElementById('scene-status').textContent; const project = director._project; let finish; - const gate = new Promise((resolve) => { finish = resolve; }); - const pending = controls.run('import', { name: 'late.json', text: async () => { await gate; return '{"scenes":[]}'; } }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const pending = controls.run('import', { + name: 'late.json', + text: async () => { + await gate; + return '{"scenes":[]}'; + }, + }); const stopping = director.destroy(); - const stoppedSynchronously = controls.destroyed && controls.removers.length === 0 && controls.rowRemovers.length === 0; + const stoppedSynchronously = + controls.destroyed && + controls.removers.length === 0 && + controls.rowRemovers.length === 0; document.getElementById('scene-new-btn').click(); controls.run('capture'); finish(); await pending; await stopping; await director.destroy(); - return { stoppedSynchronously, sameProject: director._project === project, noLateStatus: document.getElementById('scene-status').textContent === previousStatus, rowsGone: !document.querySelector('.scene-shot-row') }; + return { + stoppedSynchronously, + sameProject: director._project === project, + noLateStatus: + document.getElementById('scene-status').textContent === previousStatus, + rowsGone: !document.querySelector('.scene-shot-row'), + }; }); - check('Disposal revokes fixed and row actions before waiting for scene work', teardown.stoppedSynchronously && teardown.rowsGone); - check('Late import completion cannot replace the disposed project or status', teardown.sameProject && teardown.noLateStatus); - check('Scene control interaction produces no uncaught browser errors', errors.length === 0); -} finally { await browser.close(); } + check( + 'Disposal revokes fixed and row actions before waiting for scene work', + teardown.stoppedSynchronously && teardown.rowsGone, + ); + check( + 'Late import completion cannot replace the disposed project or status', + teardown.sameProject && teardown.noLateStatus, + ); + check( + 'Scene control interaction produces no uncaught browser errors', + errors.length === 0, + ); +} finally { + await browser.close(); +} if (failures) process.exitCode = 1; diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 26e032258..0ce6d61dd 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -1,5 +1,12 @@ /** Own Scene panel listeners and presentation through project reads and actions. */ -import { sceneElements, renderSceneOptions, renderSceneShots, presentSceneButtons, presentSceneProgress, presentSceneRuntime } from './scenePresentation.js'; +import { + sceneElements, + renderSceneOptions, + renderSceneShots, + presentSceneButtons, + presentSceneProgress, + presentSceneRuntime, +} from './scenePresentation.js'; export class SceneControls { constructor({ read, actions, elements = sceneElements() }) { @@ -14,11 +21,23 @@ export class SceneControls { if (!elements.select) return; this.renderSceneSelect(); this.renderShotList(); - this.listen(elements.select, 'change', () => this.run('selectScene', elements.select.value)); - for (const [element, action] of Object.entries({ new: 'create', delete: 'deleteScene', capture: 'capture', update: 'update', next: 'next', export: 'export', download: 'download' })) { + this.listen(elements.select, 'change', () => + this.run('selectScene', elements.select.value), + ); + for (const [element, action] of Object.entries({ + new: 'create', + delete: 'deleteScene', + capture: 'capture', + update: 'update', + next: 'next', + export: 'export', + download: 'download', + })) { this.listen(elements[element], 'click', () => this.run(action)); } - this.listen(elements.start, 'click', () => this.run('start', this.read().selectedSceneId)); + this.listen(elements.start, 'click', () => + this.run('start', this.read().selectedSceneId), + ); this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped')); this.listen(elements.import, 'click', () => elements.file?.click()); this.listen(elements.file, 'change', async () => { @@ -34,7 +53,9 @@ export class SceneControls { listen(target, type, callback, removers = this.removers) { if (!target) return; - const listener = (event) => { if (!this.destroyed) return callback(event); }; + const listener = (event) => { + if (!this.destroyed) return callback(event); + }; target.addEventListener(type, listener); removers.push(() => target.removeEventListener(type, listener)); } @@ -43,7 +64,8 @@ export class SceneControls { if (this.destroyed) return; const generation = ++this.actionGeneration; const failed = () => { - if (!this.destroyed && generation === this.actionGeneration) this.updateStatus('Scene action failed'); + if (!this.destroyed && generation === this.actionGeneration) + this.updateStatus('Scene action failed'); }; try { const result = this.actions[action](...args); @@ -61,16 +83,19 @@ export class SceneControls { if (this.destroyed) return; for (const remove of this.rowRemovers.splice(0)) remove(); renderSceneShots(this.elements.shots, this.read(), { - listen: (element, type, callback) => this.listen(element, type, callback, this.rowRemovers), + listen: (element, type, callback) => + this.listen(element, type, callback, this.rowRemovers), select: (id) => this.run('selectShot', id), - rename: (sceneId, shotId, title) => this.run('renameShot', sceneId, shotId, title), + rename: (sceneId, shotId, title) => + this.run('renameShot', sceneId, shotId, title), load: (sceneId, shotId) => this.run('load', sceneId, shotId), remove: (sceneId, shotId) => this.run('deleteShot', sceneId, shotId), }); } setButtons(running) { - if (!this.destroyed) presentSceneButtons(this.elements, running, this.read().hasRun); + if (!this.destroyed) + presentSceneButtons(this.elements, running, this.read().hasRun); } setProgress(progress) { @@ -78,7 +103,8 @@ export class SceneControls { } updateStatus(text) { - if (!this.destroyed && this.elements.status) this.elements.status.textContent = text; + if (!this.destroyed && this.elements.status) + this.elements.status.textContent = text; } updateRuntime(text) { @@ -96,10 +122,12 @@ export class SceneControls { this.playbackKeyRemover = null; } else if (!this.destroyed && !this.playbackKeyRemover) { const onKeyDown = (event) => { - if (!this.destroyed && event.key === 'Escape' && this.read().running) this.run('stop', 'Stopped (Esc)'); + if (!this.destroyed && event.key === 'Escape' && this.read().running) + this.run('stop', 'Stopped (Esc)'); }; document.addEventListener('keydown', onKeyDown); - this.playbackKeyRemover = () => document.removeEventListener('keydown', onKeyDown); + this.playbackKeyRemover = () => + document.removeEventListener('keydown', onKeyDown); } } @@ -108,7 +136,11 @@ export class SceneControls { this.destroyed = true; this.actionGeneration++; this.setPlaybackKeyboardEnabled(false); - for (const remove of [...this.removers.splice(0), ...this.rowRemovers.splice(0)]) remove(); + for (const remove of [ + ...this.removers.splice(0), + ...this.rowRemovers.splice(0), + ]) + remove(); if (this.elements.shots) this.elements.shots.textContent = ''; presentSceneRuntime(this.elements.runtime, ''); this.setPlaybackActive(false); diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index 512ffb8a6..34b450f6d 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -12,28 +12,116 @@ function fixture() { this._text = ''; const classes = new Set(); this.classList = { - toggle(name, active) { if (active) classes.add(name); else classes.delete(name); }, + toggle(name, active) { + if (active) classes.add(name); + else classes.delete(name); + }, contains: (name) => classes.has(name), }; } - appendChild(element) { this.children.push(element); } - set textContent(value) { this._text = value; this.children = []; } - get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); } - click() { this.dispatchEvent(new Event('click')); } + appendChild(element) { + this.children.push(element); + } + set textContent(value) { + this._text = value; + this.children = []; + } + get textContent() { + return ( + this._text + this.children.map((node) => node.textContent).join('') + ); + } + click() { + this.dispatchEvent(new Event('click')); + } } const saved = { document: globalThis.document, window: globalThis.window }; - const document = Object.assign(new EventTarget(), { createElement: () => new Element(), body: new Element() }); + const document = Object.assign(new EventTarget(), { + createElement: () => new Element(), + body: new Element(), + }); globalThis.document = document; globalThis.window = { prompt: () => 'Renamed' }; - const elements = Object.fromEntries(['panel','select','new','delete','capture','update','shots','start','stop','next','export','import','file','download','status','progress','runtime'].map((name) => [name, new Element()])); + const elements = Object.fromEntries( + [ + 'panel', + 'select', + 'new', + 'delete', + 'capture', + 'update', + 'shots', + 'start', + 'stop', + 'next', + 'export', + 'import', + 'file', + 'download', + 'status', + 'progress', + 'runtime', + ].map((name) => [name, new Element()]), + ); const state = { - scenes: [{ id: 'scene-a', title: '', shots: [{ id: 'shot-a', title: '', durationSec: 4, holdSec: 0.9, visual: { style: 'normal' } }] }], - selectedSceneId: 'scene-a', selectedShotId: 'shot-a', running: false, hasRun: false, + scenes: [ + { + id: 'scene-a', + title: '', + shots: [ + { + id: 'shot-a', + title: '', + durationSec: 4, + holdSec: 0.9, + visual: { style: 'normal' }, + }, + ], + }, + ], + selectedSceneId: 'scene-a', + selectedShotId: 'shot-a', + running: false, + hasRun: false, }; const calls = []; - const actions = Object.fromEntries(['selectScene','selectShot','renameShot','create','deleteScene','capture','update','start','stop','next','export','import','download','load','deleteShot'].map((name) => [name, (...args) => { calls.push([name, ...args]); }])); + const actions = Object.fromEntries( + [ + 'selectScene', + 'selectShot', + 'renameShot', + 'create', + 'deleteScene', + 'capture', + 'update', + 'start', + 'stop', + 'next', + 'export', + 'import', + 'download', + 'load', + 'deleteShot', + ].map((name) => [ + name, + (...args) => { + calls.push([name, ...args]); + }, + ]), + ); const owner = new SceneControls({ read: () => state, actions, elements }); - return { owner, state, actions, elements, calls, document, restore() { owner.destroy(); Object.assign(globalThis, saved); } }; + return { + owner, + state, + actions, + elements, + calls, + document, + restore() { + owner.destroy(); + Object.assign(globalThis, saved); + }, + }; } test('Scene controls render the supplied selection and running/download states', () => { @@ -41,7 +129,10 @@ test('Scene controls render the supplied selection and running/download states', try { assert.equal(f.elements.select.children[0].textContent, ''); assert.equal(f.elements.select.value, 'scene-a'); - assert.equal(f.elements.shots.children[0].children[0].children[0].textContent, ''); + assert.equal( + f.elements.shots.children[0].children[0].children[0].textContent, + '', + ); assert.equal(f.elements.stop.disabled, true); assert.equal(f.elements.download.disabled, true); f.state.hasRun = true; @@ -55,7 +146,9 @@ test('Scene controls render the supplied selection and running/download states', assert.equal(f.elements.progress.style.width, '100%'); f.owner.setProgress(-1); assert.equal(f.elements.progress.textContent, '0%'); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => { @@ -69,19 +162,30 @@ test('Scene controls dispatch explicit actions and revoke replaced shot-row list label.click(); label.dispatchEvent(new Event('dblclick')); load.click(); - assert.deepEqual(f.calls, [['capture'], ['start','scene-a'], ['selectShot','shot-a'], ['renameShot','scene-a','shot-a','Renamed'], ['load','scene-a','shot-a']]); + assert.deepEqual(f.calls, [ + ['capture'], + ['start', 'scene-a'], + ['selectShot', 'shot-a'], + ['renameShot', 'scene-a', 'shot-a', 'Renamed'], + ['load', 'scene-a', 'shot-a'], + ]); f.owner.renderShotList(); label.click(); load.click(); assert.equal(f.calls.length, 5); f.elements.shots.children[0].children[0].children[1].children[0].click(); assert.equal(f.calls.length, 6); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('Scene Escape and recording presentation follow playback and release on destruction', () => { const f = fixture(); - const escape = () => f.document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' })); + const escape = () => + f.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); try { escape(); assert.deepEqual(f.calls, []); @@ -98,18 +202,26 @@ test('Scene Escape and recording presentation follow playback and release on des f.elements.capture.click(); f.owner.run('capture'); assert.equal(f.calls.length, 1); - assert.equal(f.document.body.classList.contains('scene-playback-mode'), false); + assert.equal( + f.document.body.classList.contains('scene-playback-mode'), + false, + ); assert.equal(f.elements.runtime.textContent, ''); assert.equal(f.owner.removers.length, 0); assert.equal(f.owner.rowRemovers.length, 0); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('a file import finishing after disposal cannot clear the retained file control', async () => { const f = fixture(); try { let finish; - f.actions.import = () => new Promise((resolve) => { finish = resolve; }); + f.actions.import = () => + new Promise((resolve) => { + finish = resolve; + }); f.elements.file.files = [{ name: 'project.json' }]; f.elements.file.value = 'project.json'; f.elements.file.dispatchEvent(new Event('change')); @@ -118,14 +230,19 @@ test('a file import finishing after disposal cannot clear the retained file cont await new Promise((resolve) => setImmediate(resolve)); assert.equal(f.elements.file.value, 'project.json'); assert.equal(f.elements.status.textContent, 'Ready'); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('Scene action failures are visible only while that action still owns presentation', async () => { const f = fixture(); try { let reject; - f.actions.load = () => new Promise((_resolve, fail) => { reject = fail; }); + f.actions.load = () => + new Promise((_resolve, fail) => { + reject = fail; + }); let pending = f.owner.run('load', 'scene-a', 'shot-a'); reject(new Error('unavailable')); await pending; @@ -141,5 +258,7 @@ test('Scene action failures are visible only while that action still owns presen reject(new Error('disposed')); await pending; assert.equal(f.elements.status.textContent, 'Current selection'); - } finally { f.restore(); } + } finally { + f.restore(); + } }); diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js index a4a449a08..d0c0530be 100644 --- a/src/ui/scenePresentation.js +++ b/src/ui/scenePresentation.js @@ -1,14 +1,27 @@ /** Scene panel elements and text-only project presentation. */ export function sceneElements(root = document) { const ids = { - panel: 'scene-panel', select: 'scene-select', new: 'scene-new-btn', - delete: 'scene-delete-btn', capture: 'scene-capture-btn', update: 'scene-update-shot-btn', - shots: 'scene-shot-list', start: 'scene-start-btn', stop: 'scene-stop-btn', - next: 'scene-next-btn', export: 'scene-export-btn', import: 'scene-import-btn', - file: 'scene-import-file', download: 'scene-download-btn', status: 'scene-status', - progress: 'scene-progress-fill', runtime: 'scene-runtime', + panel: 'scene-panel', + select: 'scene-select', + new: 'scene-new-btn', + delete: 'scene-delete-btn', + capture: 'scene-capture-btn', + update: 'scene-update-shot-btn', + shots: 'scene-shot-list', + start: 'scene-start-btn', + stop: 'scene-stop-btn', + next: 'scene-next-btn', + export: 'scene-export-btn', + import: 'scene-import-btn', + file: 'scene-import-file', + download: 'scene-download-btn', + status: 'scene-status', + progress: 'scene-progress-fill', + runtime: 'scene-runtime', }; - return Object.fromEntries(Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)])); + return Object.fromEntries( + Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)]), + ); } export function renderSceneOptions(element, { scenes, selectedSceneId }) { @@ -23,7 +36,11 @@ export function renderSceneOptions(element, { scenes, selectedSceneId }) { if (selectedSceneId) element.value = selectedSceneId; } -export function renderSceneShots(element, state, { listen, select, rename, load, remove }) { +export function renderSceneShots( + element, + state, + { listen, select, rename, load, remove }, +) { if (!element) return; const scene = state.scenes.find((item) => item.id === state.selectedSceneId); element.textContent = ''; @@ -74,7 +91,17 @@ export function renderSceneShots(element, state, { listen, select, rename, load, } export function presentSceneButtons(elements, running, hasRun) { - for (const name of ['start', 'next', 'select', 'new', 'delete', 'capture', 'update', 'export', 'import']) { + for (const name of [ + 'start', + 'next', + 'select', + 'new', + 'delete', + 'capture', + 'update', + 'export', + 'import', + ]) { if (elements[name]) elements[name].disabled = running; } if (elements.stop) elements.stop.disabled = !running; From 9b6d34cb8a722875397a8b4c49b388191b54539e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 03:35:22 -0700 Subject: [PATCH 51/92] refactor: keep Scene prompts with their controls --- docs/CODE-BOUNDARIES.md | 2 +- docs/CURRENT-STATE.md | 2 +- src/scenes/director.js | 13 +++-------- src/ui/sceneControls.js | 33 ++++++++++++++++++++++++--- src/ui/sceneControls.test.mjs | 42 +++++++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 15 deletions(-) diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index cce4e8f20..2ba08e77b 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -323,7 +323,7 @@ original Display groups, independent scroll positions and current focus owner. ## Scene controls -`ui/scenes` owns Scene panel input, project/shot rows, playback button and runtime +`ui/scenes` owns Scene prompts, panel input, project/shot rows, playback button and runtime presentation. It receives project reads and explicit actions, with no imports of the director, source modules, camera engine or storage. Replacement and disposal release listeners; pending action feedback is limited to its current owner. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5e267ab59..5ff2468c2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2,7 +2,7 @@ ## Scene control ownership -Scene controls own panel listeners, shot rows, playback/recording presentation +Scene controls own creation/deletion prompts, panel listeners, shot rows, playback/recording presentation and keyboard cancellation. The director supplies project reads and explicit editing/playback actions while retaining persistence, camera and layer sequencing. Replacing rows revokes their old listeners. Disposal stops controls immediately; diff --git a/src/scenes/director.js b/src/scenes/director.js index 9160fcc4d..d98929260 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -417,7 +417,7 @@ export class SceneDirector { this._saveProject(); this._renderShotList(); }, - create: () => this._createScene(), + create: (name) => this._createScene(name), deleteScene: () => this._deleteSelectedScene(), capture: () => this.captureShot(), update: () => this.updateSelectedShot(), @@ -474,9 +474,8 @@ export class SceneDirector { return { scene, shot }; } - /** Prompt the user for a name and append a new empty scene to the project. */ - _createScene() { - const sceneName = window.prompt('New scene name', `Scene ${this._project.scenes.length + 1}`); + /** Append a named empty scene after the controls accept the creation prompt. */ + _createScene(sceneName) { if (!sceneName) return; const scene = { @@ -498,9 +497,6 @@ export class SceneDirector { const scene = this._getSelectedScene(); if (!scene) return; - const ok = window.confirm(`Delete scene "${scene.title}" and all shots?`); - if (!ok) return; - this._project.scenes = this._project.scenes.filter((item) => item.id !== scene.id); // Restore default recipes if the user deleted all scenes if (!this._project.scenes.length) { @@ -596,9 +592,6 @@ export class SceneDirector { const { scene, shot } = this._getShot(sceneId, shotId); if (!scene || !shot) return; - const ok = window.confirm(`Delete shot "${shot.title}"?`); - if (!ok) return; - scene.shots = scene.shots.filter((item) => item.id !== shot.id); this._selectedShotId = scene.shots[0]?.id || null; this._saveProject(); diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 0ce6d61dd..781310436 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -25,8 +25,6 @@ export class SceneControls { this.run('selectScene', elements.select.value), ); for (const [element, action] of Object.entries({ - new: 'create', - delete: 'deleteScene', capture: 'capture', update: 'update', next: 'next', @@ -35,6 +33,8 @@ export class SceneControls { })) { this.listen(elements[element], 'click', () => this.run(action)); } + this.listen(elements.new, 'click', () => this.createScene()); + this.listen(elements.delete, 'click', () => this.deleteSelectedScene()); this.listen(elements.start, 'click', () => this.run('start', this.read().selectedSceneId), ); @@ -79,6 +79,33 @@ export class SceneControls { if (!this.destroyed) renderSceneOptions(this.elements.select, this.read()); } + createScene() { + if (this.destroyed) return; + const name = window.prompt( + 'New scene name', + `Scene ${this.read().scenes.length + 1}`, + ); + if (name) this.run('create', name); + } + + deleteSelectedScene() { + if (this.destroyed) return; + const state = this.read(); + const scene = state.scenes.find( + (item) => item.id === state.selectedSceneId, + ); + if (scene && window.confirm(`Delete scene "${scene.title}" and all shots?`)) + this.run('deleteScene'); + } + + deleteShot(sceneId, shotId) { + if (this.destroyed) return; + const scene = this.read().scenes.find((item) => item.id === sceneId); + const shot = scene?.shots.find((item) => item.id === shotId); + if (shot && window.confirm(`Delete shot "${shot.title}"?`)) + this.run('deleteShot', sceneId, shotId); + } + renderShotList() { if (this.destroyed) return; for (const remove of this.rowRemovers.splice(0)) remove(); @@ -89,7 +116,7 @@ export class SceneControls { rename: (sceneId, shotId, title) => this.run('renameShot', sceneId, shotId, title), load: (sceneId, shotId) => this.run('load', sceneId, shotId), - remove: (sceneId, shotId) => this.run('deleteShot', sceneId, shotId), + remove: (sceneId, shotId) => this.deleteShot(sceneId, shotId), }); } diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index 34b450f6d..8194264ed 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -262,3 +262,45 @@ test('Scene action failures are visible only while that action still owns presen f.restore(); } }); + +test('creation and deletion prompts precede their model actions and remain inert after disposal', () => { + const f = fixture(); + try { + let prompts = 0; + window.prompt = () => { + prompts++; + return null; + }; + window.confirm = () => { + prompts++; + return false; + }; + f.elements.new.click(); + f.elements.delete.click(); + f.owner.deleteShot('scene-a', 'shot-a'); + assert.deepEqual(f.calls, []); + window.prompt = () => { + prompts++; + return 'New title'; + }; + window.confirm = () => { + prompts++; + return true; + }; + f.elements.new.click(); + f.elements.delete.click(); + f.owner.deleteShot('scene-a', 'shot-a'); + assert.deepEqual(f.calls, [ + ['create', 'New title'], + ['deleteScene'], + ['deleteShot', 'scene-a', 'shot-a'], + ]); + f.owner.destroy(); + f.owner.createScene(); + f.owner.deleteSelectedScene(); + f.owner.deleteShot('scene-a', 'shot-a'); + assert.equal(prompts, 6); + } finally { + f.restore(); + } +}); From 1f76f8148459ae824b4ba2824354a56f0e2667f4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 03:56:23 -0700 Subject: [PATCH 52/92] fix: preserve Scene shot labels for double-click rename --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 3 ++- src/scenes/director.js | 2 +- src/ui/sceneControls.js | 10 +++++++- src/ui/sceneControls.test.mjs | 45 +++++++++++++++++++++++++++++++++++ src/ui/scenePresentation.js | 7 ++++++ 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3602f2b3..bc10802f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] - Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback. +- Preserve shot-label identity on selection so double-click rename can complete. - Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations. - Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5ff2468c2..7bf3b3b00 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5,7 +5,8 @@ Scene controls own creation/deletion prompts, panel listeners, shot rows, playback/recording presentation and keyboard cancellation. The director supplies project reads and explicit editing/playback actions while retaining persistence, camera and layer sequencing. -Replacing rows revokes their old listeners. Disposal stops controls immediately; +Shot selection updates the highlight without replacing the row, preserving +native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately; late file and failed-action completions cannot update removed presentation. ## Cockpit component ownership diff --git a/src/scenes/director.js b/src/scenes/director.js index d98929260..69af3429f 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -409,7 +409,7 @@ export class SceneDirector { this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null; this._renderShotList(); }, - selectShot: (id) => { this._selectedShotId = id; this._renderShotList(); }, + selectShot: (id) => { this._selectedShotId = id; }, renameShot: (sceneId, shotId, title) => { const { shot } = this._getShot(sceneId, shotId); if (!shot) return; diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 781310436..ec1a99d3b 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -3,6 +3,7 @@ import { sceneElements, renderSceneOptions, renderSceneShots, + presentSceneSelection, presentSceneButtons, presentSceneProgress, presentSceneRuntime, @@ -112,7 +113,14 @@ export class SceneControls { renderSceneShots(this.elements.shots, this.read(), { listen: (element, type, callback) => this.listen(element, type, callback, this.rowRemovers), - select: (id) => this.run('selectShot', id), + select: (id) => { + this.run('selectShot', id); + if (!this.destroyed) + presentSceneSelection( + this.elements.shots, + this.read().selectedShotId, + ); + }, rename: (sceneId, shotId, title) => this.run('renameShot', sceneId, shotId, title), load: (sceneId, shotId) => this.run('load', sceneId, shotId), diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index 8194264ed..ca6974ded 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { SceneControls } from './sceneControls.js'; +import { SceneDirector } from '../scenes/director.js'; function fixture() { class Element extends EventTarget { @@ -8,6 +9,7 @@ function fixture() { super(); this.children = []; this.style = {}; + this.dataset = {}; this.value = ''; this._text = ''; const classes = new Set(); @@ -304,3 +306,46 @@ test('creation and deletion prompts precede their model actions and remain inert f.restore(); } }); + +test('the real director preserves a selected shot label for the following double-click', async () => { + const f = fixture(); + const previousStorage = globalThis.localStorage; + let director; + try { + f.owner.destroy(); + const ids = { + 'scene-panel': 'panel', + 'scene-select': 'select', + 'scene-new-btn': 'new', + 'scene-delete-btn': 'delete', + 'scene-capture-btn': 'capture', + 'scene-update-shot-btn': 'update', + 'scene-shot-list': 'shots', + 'scene-start-btn': 'start', + 'scene-stop-btn': 'stop', + 'scene-next-btn': 'next', + 'scene-export-btn': 'export', + 'scene-import-btn': 'import', + 'scene-import-file': 'file', + 'scene-download-btn': 'download', + 'scene-status': 'status', + 'scene-progress-fill': 'progress', + 'scene-runtime': 'runtime', + }; + f.document.getElementById = (id) => f.elements[ids[id]] || null; + globalThis.localStorage = { + getItem: () => JSON.stringify({ version: 3, scenes: f.state.scenes }), + setItem() {}, + }; + director = new SceneDirector({ camera: { cancelFlight() {} } }, {}, {}); + const label = f.elements.shots.children[0].children[0].children[0]; + label.click(); + assert.equal(f.elements.shots.children[0].children[0].children[0], label); + label.dispatchEvent(new Event('dblclick')); + assert.equal(director._getSelectedScene().shots[0].title, 'Renamed'); + } finally { + await director?.destroy(); + globalThis.localStorage = previousStorage; + f.restore(); + } +}); diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js index d0c0530be..700fc63dc 100644 --- a/src/ui/scenePresentation.js +++ b/src/ui/scenePresentation.js @@ -54,6 +54,7 @@ export function renderSceneShots( for (const shot of scene.shots) { const row = document.createElement('div'); row.className = 'scene-shot-row'; + row.dataset.sceneShotId = shot.id; row.classList.toggle('active', shot.id === state.selectedShotId); const top = document.createElement('div'); top.className = 'scene-shot-top'; @@ -90,6 +91,12 @@ export function renderSceneShots( } } +export function presentSceneSelection(element, selectedShotId) { + for (const row of element?.children || []) { + row.classList.toggle('active', row.dataset.sceneShotId === selectedShotId); + } +} + export function presentSceneButtons(elements, running, hasRun) { for (const name of [ 'start', From 6bd755109fdba988156e84856c4175714012f814 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 03:56:23 -0700 Subject: [PATCH 53/92] test: sample corridor floors beyond cell-boundary hysteresis --- scripts/track-regression.mjs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 4b1fd7df8..c5832ecd4 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -3203,10 +3203,25 @@ async function main() { await fl.update(v); await window.__dfSettle(600); } - const bb1 = window.__dfFindBB('aaa097'); - const d1 = bb1 ? window.__dfCarto(bb1.position) : null; + // The floor owner intentionally retains the previous cell near an + // edge. Sample inside a cell, beyond that hysteresis band, so the raw + // coordinate's floor is unambiguously the floor the sprite must use. + // Wait for geometry, not a passing height; a missing clamp still fails. + const interiorLimit = 0.0005 - gf.CELL_HYSTERESIS_DEG - 0.00002; + const sampleDeadline = Date.now() + 8000; + let d1 = null; + let sampleInterior = false; + do { + await window.__dfSettle(250); + const bb1 = window.__dfFindBB('aaa097'); + d1 = bb1 ? window.__dfCarto(bb1.position) : null; + sampleInterior = !!d1 + && Math.abs(d1.lat - cell(d1.lat)) < interiorLimit + && Math.abs(d1.lon - cell(d1.lon)) < interiorLimit; + } while (!sampleInterior && Date.now() < sampleDeadline); return { startCold, + sampleInterior, displayCell, fixCell, sameCellAsFix: displayCell.lat === fixCell.lat && displayCell.lon === fixCell.lon, @@ -3249,6 +3264,10 @@ async function main() { dfCorridor.controlFloor == null, `control cell floor = ${dfCorridor.controlFloor}`); + record('display-floor/corridor: the height sample clears cell-boundary hysteresis', + dfCorridor.sampleInterior === true, + 'the sampled coordinate lies inside one unambiguous floor cell'); + record('display-floor/corridor: the sprite rides the corridor-warmed floor', Number.isFinite(dfCorridor.spriteH) && Number.isFinite(dfCorridor.spriteFloor) && dfCorridor.spriteH >= dfCorridor.spriteFloor + DISPLAY_FLOOR_LIFT_M - 0.5, From fac2935d309e5f0ee7f9ba125df10978ae2102fb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 04:01:42 -0700 Subject: [PATCH 54/92] test: exercise native Scene double-click and recording state --- scripts/qa-scene-controls.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index f6606de27..4c82c347f 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -78,7 +78,7 @@ try { }), ); promptValue = 'QA shot'; - await page.click('.scene-shot-label', { clickCount: 2 }); + await page.click('.scene-shot-label', { count: 2 }); await page.waitForFunction( () => document.querySelector('.scene-shot-label')?.textContent === @@ -189,7 +189,7 @@ try { document.getElementById('scene-capture-btn').disabled && document.getElementById('scene-start-btn').disabled && !document.getElementById('scene-stop-btn').disabled && - window.__godsEyeView.styleManager.getViewState().recording, + window.__godsEyeView.styleManager.getControlState().recording, ), ); await page.keyboard.press('Escape'); @@ -205,7 +205,7 @@ try { !document.getElementById('scene-start-btn').disabled && document.getElementById('scene-stop-btn').disabled && !document.getElementById('scene-download-btn').disabled && - !window.__godsEyeView.styleManager.getViewState().recording, + !window.__godsEyeView.styleManager.getControlState().recording, ), ); await page.click('#scene-download-btn'); From f5ef9091f0d43d1bccdf737e7d77e86ae3e49d1e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 04:26:09 -0700 Subject: [PATCH 55/92] refactor: compose UI shell with explicit application operations and component owners --- CHANGELOG.md | 4 + docs/CODE-BOUNDARIES.md | 11 + docs/CURRENT-STATE.md | 14 + package.json | 4 +- scripts/package-boundaries.json | 131 + scripts/qa-map-source-tray.mjs | 6 +- scripts/qa-radio.mjs | 56 +- scripts/qa-scene-controls.mjs | 5 + scripts/qa-ui-disposal.mjs | 7 +- src/cameraHandoff.test.mjs | 2 +- src/cctvFocusPolicy.test.mjs | 4 +- src/cctvFocusRequest.test.mjs | 2 +- src/cockpitMarkup.test.mjs | 20 +- src/contactsDetectionPolicy.test.mjs | 2 +- src/contextSessionOrdering.test.mjs | 2 +- src/contextTabKeyboard.test.mjs | 5 +- src/creditAttribution.test.mjs | 10 +- src/data/detectionHost.test.mjs | 2 +- src/data/layerState.test.mjs | 2 +- src/data/tr3bRegistry.test.mjs | 2 +- src/firstRunExperience.test.mjs | 11 +- src/keyboardFocusStyles.test.mjs | 3 +- src/loadingFeedback.test.mjs | 29 +- src/locationReadouts.test.mjs | 2 +- src/mapSourceFocus.test.mjs | 4 +- src/mapStackChips.test.mjs | 7 +- src/overlays/worldOverlay.test.mjs | 7 +- src/panelEscape.test.mjs | 2 +- src/panelStackLayout.test.mjs | 25 +- src/radioMarkup.test.mjs | 7 +- src/reasonableDefaults.test.mjs | 2 +- src/rightRailPolicy.test.mjs | 3 +- src/scenes/director.test.mjs | 2 +- src/sharelink.celestial.test.mjs | 2 +- src/splitFlap.js | 10 + src/standalone/ui.js | 133 + src/testSupport/readStylesheet.mjs | 11 + src/ui.js | 5272 +------------ src/ui/applicationShell.js | 4421 +++++++++++ src/ui/panelLayoutController.js | 386 + src/ui/panelPositionControls.js | 260 + src/ui/recordingControls.js | 74 + src/ui/shellElements.js | 141 + src/ui/shellFeedback.js | 168 + src/ui/shellLifecycle.test.mjs | 139 + src/ui/styles.js | 2 + src/ui/styles/cctv.css | 338 + src/ui/styles/cockpit.css | 1814 +++++ src/ui/styles/command-dock-compact.css | 404 + src/ui/styles/command-dock-sliding.css | 631 ++ src/ui/styles/command-dock-trays.css | 483 ++ src/ui/styles/command-dock.css | 301 + src/ui/styles/controls.css | 1061 +++ src/ui/styles/first-run.css | 315 + src/ui/styles/foundation.css | 611 ++ src/ui/styles/layers.css | 1067 +++ src/ui/styles/location.css | 292 + src/ui/styles/overlays.css | 364 + src/ui/styles/provider-settings.css | 355 + src/ui/styles/radio.css | 576 ++ src/ui/styles/recording.css | 33 + src/ui/styles/responsive.css | 216 + src/ui/styles/scenes.css | 412 + src/ui/styles/status.css | 334 + src/ui/styles/voice-cost.css | 78 + src/ui/uiLifetime.js | 54 + style.css | 9705 +----------------------- 67 files changed, 15799 insertions(+), 15059 deletions(-) create mode 100644 src/standalone/ui.js create mode 100644 src/testSupport/readStylesheet.mjs create mode 100644 src/ui/applicationShell.js create mode 100644 src/ui/panelLayoutController.js create mode 100644 src/ui/panelPositionControls.js create mode 100644 src/ui/recordingControls.js create mode 100644 src/ui/shellElements.js create mode 100644 src/ui/shellFeedback.js create mode 100644 src/ui/shellLifecycle.test.mjs create mode 100644 src/ui/styles.js create mode 100644 src/ui/styles/cctv.css create mode 100644 src/ui/styles/cockpit.css create mode 100644 src/ui/styles/command-dock-compact.css create mode 100644 src/ui/styles/command-dock-sliding.css create mode 100644 src/ui/styles/command-dock-trays.css create mode 100644 src/ui/styles/command-dock.css create mode 100644 src/ui/styles/controls.css create mode 100644 src/ui/styles/first-run.css create mode 100644 src/ui/styles/foundation.css create mode 100644 src/ui/styles/layers.css create mode 100644 src/ui/styles/location.css create mode 100644 src/ui/styles/overlays.css create mode 100644 src/ui/styles/provider-settings.css create mode 100644 src/ui/styles/radio.css create mode 100644 src/ui/styles/recording.css create mode 100644 src/ui/styles/responsive.css create mode 100644 src/ui/styles/scenes.css create mode 100644 src/ui/styles/status.css create mode 100644 src/ui/styles/voice-cost.css create mode 100644 src/ui/uiLifetime.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bc10802f5..f511bf044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate UI assembly from standalone engine wiring, with dedicated panel layout, position, notice and recording owners. +- Stop pending UI presentation and drag work during disposal; preserve accessible status text when stopping its decoration. +- Organize component styles behind the same ordered stylesheet entry and include 3D model controls in the current-state snapshot. + - Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback. - Preserve shot-label identity on selection so double-click rename can complete. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 2ba08e77b..1fc301747 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -327,3 +327,14 @@ original Display groups, independent scroll positions and current focus owner. presentation. It receives project reads and explicit actions, with no imports of the director, source modules, camera engine or storage. Replacement and disposal release listeners; pending action feedback is limited to its current owner. + +## UI assembly and styles + +`ui/shell` assembles controls from supplied existing layer, navigation, terrain, +rendering, HUD and share operations. `src/standalone/ui.js` provides the running +application's instances; `src/ui.js` remains the compatibility entry. The shell +imports no standalone bootstrap or concrete live layer implementation. +Panel layout, position/drag, notices, recording and deferred UI work have separate +owners with synchronous cleanup. Existing scene, share and HUD engines retain +their entry points. `ui/styles` loads the ordered stylesheet entry; component +files retain the original cascade, including responsive and dock refinements. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7bf3b3b00..92f38babb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,19 @@ # God's Eye View Current State +## UI shell and component ownership + +The standalone entry composes the UI with the application's existing layer, +terrain, navigation and rendering operations. The shell receives those instances +and assembles the controls. Panel layout scheduling, position preferences and +active drags, loading notices, recording presentation and DOM lookup have focused +owners. Disposal revokes queued presentation and listeners before asynchronous +Context restoration, cancels an unfinished drag without saving it, restores the +recording HUD and releases status decoration without replacing accessible text. +The ordinary control snapshot includes the current 3D model toggle and mode. + +`style.css` imports component styles in their original cascade order. Scene, +share, HUD and layer engines retain their existing behavior and entry points. + ## Scene control ownership Scene controls own creation/deletion prompts, panel listeners, shot rows, playback/recording presentation diff --git a/package.json b/package.json index d0bead40e..e7fa939f5 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,8 @@ "./ui/cockpit/math": "./src/cockpitMath.js", "./ui/cockpit/layout": "./src/cockpitUtilityLayout.js", "./ui/cockpit/vision": "./src/cockpitVisionPolicy.js", - "./ui/scenes": "./src/ui/scenes.js" + "./ui/scenes": "./src/ui/scenes.js", + "./ui/shell": "./src/ui/applicationShell.js", + "./ui/styles": "./src/ui/styles.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 16b60d839..0cc639119 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -423,5 +423,136 @@ "src/ui/scenePresentation.js" ], "external": [] + }, + "ui-shell": { + "exports": [ + "./ui/shell" + ], + "modules": [ + "src/bloom.js", + "src/cctvFocusPolicy.js", + "src/cctvFocusRequest.js", + "src/cockpitMath.js", + "src/cockpitTracking.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/contactsDetectionPolicy.js", + "src/contextModePolicy.js", + "src/data/detectionPolicy.js", + "src/data/installationFeedback.js", + "src/data/layerState.js", + "src/data/militaryAwarenessEngine.js", + "src/keySetupCore.mjs", + "src/loadingFeedback.js", + "src/locationStatus.js", + "src/mapStackChips.js", + "src/navigationPolicy.js", + "src/panelStackLayout.js", + "src/rightRailPolicy.js", + "src/splitFlap.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/retro.js", + "src/styles/snow.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/ui/applicationShell.js", + "src/ui/applicationShortcuts.js", + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js", + "src/ui/clearLayersControl.js", + "src/ui/cockpit.js", + "src/ui/cockpitBriefing.js", + "src/ui/cockpitCamera.js", + "src/ui/cockpitContext.js", + "src/ui/cockpitController.js", + "src/ui/cockpitDisplayPortal.js", + "src/ui/cockpitInput.js", + "src/ui/cockpitInstruments.js", + "src/ui/cockpitLayout.js", + "src/ui/cockpitPresentation.js", + "src/ui/cockpitSignals.js", + "src/ui/cockpitTrackingController.js", + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js", + "src/ui/displayControls.js", + "src/ui/effects.js", + "src/ui/layerPanel.js", + "src/ui/layers.js", + "src/ui/leftPanelRail.js", + "src/ui/location.js", + "src/ui/locationControls.js", + "src/ui/locationSearch.js", + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js", + "src/ui/panelDisclosure.js", + "src/ui/panelLayoutController.js", + "src/ui/panelMeasurement.js", + "src/ui/panelPositionControls.js", + "src/ui/panelRailGeometry.js", + "src/ui/panelRails.js", + "src/ui/radio.js", + "src/ui/radioBindings.js", + "src/ui/radioControls.js", + "src/ui/radioPresentation.js", + "src/ui/radioTunerModel.js", + "src/ui/recordingControls.js", + "src/ui/rightPanelRail.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", + "src/ui/scenes.js", + "src/ui/shellElements.js", + "src/ui/shellFeedback.js", + "src/ui/styleParameters.js", + "src/ui/surfaceKeyboard.js", + "src/ui/visualEffects.js", + "src/ui/visualInput.js", + "src/ui/visualPresets.js", + "src/worldFocus.js", + "src/ui/uiLifetime.js" + ], + "external": [ + "cesium" + ] + }, + "ui-styles": { + "exports": [ + "./ui/styles" + ], + "modules": [ + "src/ui/styles.js", + "style.css", + "src/ui/styles/foundation.css", + "src/ui/styles/controls.css", + "src/ui/styles/location.css", + "src/ui/styles/status.css", + "src/ui/styles/cockpit.css", + "src/ui/styles/overlays.css", + "src/ui/styles/layers.css", + "src/ui/styles/radio.css", + "src/ui/styles/cctv.css", + "src/ui/styles/scenes.css", + "src/ui/styles/recording.css", + "src/ui/styles/responsive.css", + "src/ui/styles/command-dock.css", + "src/ui/styles/command-dock-compact.css", + "src/ui/styles/command-dock-trays.css", + "src/ui/styles/command-dock-sliding.css", + "src/ui/styles/voice-cost.css", + "src/ui/styles/first-run.css", + "src/ui/styles/provider-settings.css" + ], + "external": [] } } diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 5d5854bc6..149290ce9 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -918,15 +918,15 @@ try { }); const waitForQueuedNotice = async (label, timeoutMs = 1000) => { const deadline = performance.now() + timeoutMs; - while (styleManager._globalStatusNotice?.label !== label + while (styleManager._feedback._globalStatusNotice?.label !== label && performance.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 16)); } - return styleManager._globalStatusNotice?.label === label; + return styleManager._feedback._globalStatusNotice?.label === label; }; const baseNow = performance.now(); try { - styleManager._loadingFeedbackState = { + styleManager._feedback._loadingFeedbackState = { phase: 'idle', visible: false, startedAt: 0, showAt: 0, hideAt: 0, activeIds: [], batchOutcome: null, terminal: null, operation: null, }; diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 40f34240f..d3c26471c 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1702,8 +1702,8 @@ async function main() { dataset: { ...stack.dataset }, }]; })), - preferredLeftPanelId: manager._leftStackPreferredPanelId, - preferredRightPanelId: manager._rightStackPreferredPanelId, + preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId, + preferredRightPanelId: manager._panelLayout._rightStackPreferredPanelId, hudMode: manager.hud.getMode(), hudVariant: manager.hud.getVariant(), focusId: document.activeElement?.id || null, @@ -1767,7 +1767,7 @@ async function main() { const scenePanel = document.getElementById('scene-panel'); const contextPanel = document.getElementById('global-context-panel'); result.tacticalAutoCollapse = { - latestOpenedPanel: manager._leftStackPreferredPanelId, + latestOpenedPanel: manager._panelLayout._leftStackPreferredPanelId, scenesExpanded: !scenePanel.classList.contains('collapsed'), dataCollapsed: dataPanel.classList.contains('layout-auto-collapsed'), dataHiddenWhileScenesOwnsLane: dataPanel.getBoundingClientRect().height === 0 @@ -1814,13 +1814,13 @@ async function main() { manager.hud.setMode(prior.hudMode); manager._updateHudButtonState(); await new Promise((resolve) => setTimeout(resolve, 320)); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } for (const [id, state] of Object.entries(prior.panels)) { const panel = document.getElementById(id); @@ -1839,18 +1839,18 @@ async function main() { for (const key of Object.keys(stack.dataset)) delete stack.dataset[key]; Object.assign(stack.dataset, state.dataset); } - manager._leftStackPreferredPanelId = prior.preferredLeftPanelId; - manager._rightStackPreferredPanelId = prior.preferredRightPanelId; + manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId; + manager._panelLayout._rightStackPreferredPanelId = prior.preferredRightPanelId; localStorage.clear(); for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } // Disclosure restoration can wake the installed layout observers. Let // those observers settle, then restore the captured stack presentation @@ -1880,8 +1880,8 @@ async function main() { result.restoreDetails = { panelsRestored, stacksRestored, - preferredPanelRestored: manager._leftStackPreferredPanelId === prior.preferredLeftPanelId, - preferredRightPanelRestored: manager._rightStackPreferredPanelId === prior.preferredRightPanelId, + preferredPanelRestored: manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId, + preferredRightPanelRestored: manager._panelLayout._rightStackPreferredPanelId === prior.preferredRightPanelId, hudModeRestored: manager.hud.getMode() === prior.hudMode, hudVariantRestored: manager.hud.getVariant() === prior.hudVariant, storageRestored: JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage), @@ -2055,7 +2055,7 @@ async function main() { detectionMode: manager.getDetectionState().detectionMode, models3dEnabled: manager._models3dEnabled, models3dMode: manager._models3dMode, - preferredLeftPanelId: manager._leftStackPreferredPanelId, + preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId, panels: Object.fromEntries([ 'data-panel', 'global-context-panel', @@ -2371,13 +2371,13 @@ async function main() { if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop); else hud.style.removeProperty('--cockpit-utility-top'); await waitForLayout(); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } for (const [id, state] of Object.entries(prior.panels)) { const panel = document.getElementById(id); @@ -2388,7 +2388,7 @@ async function main() { else panel.setAttribute('aria-hidden', state.ariaHidden); manager._syncPanelCollapseButton(panel); } - manager._leftStackPreferredPanelId = prior.preferredLeftPanelId; + manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId; localStorage.clear(); for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); @@ -2411,7 +2411,7 @@ async function main() { && panel.getAttribute('style') === state.style && panel.getAttribute('aria-hidden') === state.ariaHidden; }) - && manager._leftStackPreferredPanelId === prior.preferredLeftPanelId + && manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId && JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage) && (!prior.focusId || document.activeElement?.id === prior.focusId); } diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index 4c82c347f..916561b40 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -208,6 +208,11 @@ try { !window.__godsEyeView.styleManager.getControlState().recording, ), ); + // Escape can also collapse the Scene accordion. Reopen it through its + // installed disclosure before testing the next visible user action. + if (await page.$eval('#scene-panel', (panel) => panel.classList.contains('collapsed'))) + await page.click('[data-collapse-target="scene-panel"]'); + await page.waitForSelector('#scene-download-btn', { visible: true }); await page.click('#scene-download-btn'); const metadata = await page.evaluate(async () => JSON.parse(await window.__sceneExports.at(-1)), diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 2808adfa0..7bf0b7649 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -33,7 +33,8 @@ try { const ui = window.__godsEyeView.styleManager; const counts = {}; const watchObserver = (name) => { - const observer = ui[name]; + const owner = name === '_draggableResizeObserver' ? ui._panelPosition : ui._panelLayout; + const observer = owner[name]; if (!observer) return false; const disconnect = observer.disconnect.bind(observer); counts[name] = 0; @@ -113,6 +114,10 @@ try { const disposal = ui.dispose(); const focusBefore = document.activeElement; const stoppedBeforeRestoration = + ui._panelLayout.destroyed && + ui._panelPosition.destroyed && + ui._feedback.destroyed && + ui._recording.destroyed && cockpit.destroyed && portal.stopped && cockpit._listenerRemovers.length === 0 && diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 3ef065443..eecd53bbc 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -7,7 +7,7 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8'); +const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); const firms = fs.readFileSync(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), 'utf8'); const vessels = fs.readFileSync(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'), 'utf8'); const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 5f4abd82e..3574bdf78 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -73,7 +73,7 @@ test('CCTV enable retains a pre-await tracking snapshot when tracking clears dur assert.match(diagnostics[0][0], /before setEnabled await/); assert.match(diagnostics[1][0], /after setEnabled await/); - const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/); }); @@ -114,6 +114,6 @@ test('a disposed UI cannot activate a camera when enabling finishes late', async release(); await pending; assert.equal(activations, 0); - const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.match(uiSource, /shouldFocus: \(\) => !this\._disposed && this\._dataManager\.isEnabled\('cctv'\)/); }); diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs index fee23eb5e..64b4c20de 100644 --- a/src/cctvFocusRequest.test.mjs +++ b/src/cctvFocusRequest.test.mjs @@ -57,7 +57,7 @@ test('UI CCTV focus listener registration disposes the exact added callback once assert.strictEqual(added[0].callback, listener); assert.strictEqual(removed[0].callback, added[0].callback); - const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.match( uiSource, /_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index e67fae046..aa4b83792 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,3 +1,6 @@ +import { PanelLayoutController } from './ui/panelLayoutController.js'; +import { readShellElements } from './ui/shellElements.js'; +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { SceneControls } from './ui/sceneControls.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; @@ -23,8 +26,8 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'); -const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8'); -const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8'); +const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); +const css = readStylesheet(path.join(ROOT, 'style.css')); const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8'); const contextLayer = fs.readFileSync(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); @@ -299,7 +302,7 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below ); assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1, 'Parameters must have one DOM owner'); assert.match( - ui, + PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(), /const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/, ); assert.match( @@ -476,7 +479,7 @@ test('Reset releases Contact camera ownership through its selection-preserving r assert.ok(resetStart >= 0); assert.ok(contextRelease > resetStart); assert.ok(satelliteRelease > contextRelease); - assert.match(ui, /this\._cockpitResetGlobeBtn = document\.getElementById\('cockpit-reset-globe'\)/); + assert.match(readShellElements.toString(), /_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/); const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8'); assert.match(ui, /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/); assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/); @@ -530,8 +533,9 @@ test('Cockpit Radio station changes preserve first-person camera ownership', () }); test('Cockpit panel corridors reserve the owned topline readouts', () => { - const leftObstacles = ui.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); - const rightObstacles = ui.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); + const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'); + const leftObstacles = layout.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); + const rightObstacles = layout.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); assert.ok(leftObstacles && rightObstacles, 'responsive panel obstacle selectors are missing'); assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline/); assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/); @@ -569,8 +573,8 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { /function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none'\s*\|\|\s*style\.visibility === 'hidden'\s*\|\|\s*Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/, ); assert.match( - ui, - /_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.cockpitView\?\.scheduleContextLayout\(\);/, + layout, + /_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.scheduleCockpitLayout\(\);/, 'the strip must remeasure on the same HUD fade the accordion does — the REC ' + 'readout keeps its rect until the transition ends', ); diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 4307cc182..331151f8e 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -28,7 +28,7 @@ import { import { canonicalizeDensity } from './data/detectionPolicy.js'; // Follow the UI wiring and its extracted preset definitions. -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8') +const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8') + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); /** diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 7fe6a8288..af836c793 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -23,7 +23,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8'); +const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8'); const handler = _handleContextLayerChange.toString(); diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 20b972967..49a640d97 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { _selectContextMode } from './ui/contextTransactions.js'; import { _syncContextModeButtons } from './ui/contextPresentation.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js'; @@ -11,8 +12,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); -const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); -const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) { diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index dc1c17136..7dd7d0b70 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -5,8 +6,8 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8'); -const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8'); +const css = readStylesheet(path.join(ROOT, 'style.css')); +const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); /* * Required-attribution keep-out pin. @@ -436,9 +437,10 @@ test('custom properties inside modelled offsets are provably non-negative', () = assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`); assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`); } - const start = ui.indexOf('_updateCommandDockTrayStack() {'); + const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'); + const start = layout.indexOf('_updateCommandDockTrayStack() {'); assert.ok(start > 0, '_updateCommandDockTrayStack is missing'); - const writer = ui.slice(start, start + 1800); + const writer = layout.slice(start, start + 1800); // Every value traces back to a rect height, floored at 0 and rounded up. assert.match(writer, /const locationHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/); assert.match(writer, /const presetsHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index b51776ff5..e76f6a75f 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -671,7 +671,7 @@ test('pathological detection paint holds alternate frames without freezing share test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => { const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); - const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8') + const uiSource = readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8') + '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/); assert.doesNotMatch(source, /postRender\.addEventListener/); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2c282e75e..0080ef9ab 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -371,7 +371,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an assert.match(source, /^let _models3dMode = 'proximity';/m, `${name}: and starts in proximity, matching the codec default`); } - const ui = await readFile(new URL('../ui.js', import.meta.url), 'utf8'); + const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8'); assert.match(ui, /^\s*this\._models3dEnabled = true;$/m, 'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives'); assert.match(ui, /this\._models3dMode = 'proximity';/, diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index f7f00b0bd..1099127fb 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -270,7 +270,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async ( // personally clicked, and re-tracking the same aircraft after a layer restart // should still show the triangle. Only a page reload resets it — so no // production code may clear the registry. - for (const name of ['flights.js', 'militaryFlights.js', '../ui.js']) { + for (const name of ['flights.js', 'militaryFlights.js', '../ui/applicationShell.js']) { const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); assert.doesNotMatch(source, /clearTr3bRegistry/, `${name}: teardown must not clear session conversions`); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index b19f082ae..002959ba9 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -183,7 +184,7 @@ test('no storage is touched from a default parameter position', () => { test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.deepEqual( [...EXCLUSIVE_SURFACE_CLASSES].sort(), @@ -233,7 +234,7 @@ test('the key handler refuses to act for a card that is not really on screen', ( test('an overlay with NO class to watch still disarms the launcher', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); // The repro, pinned as the stacking it actually is: the attribution lightbox // is full-screen ABOVE the card and announces itself with nothing. The card @@ -345,7 +346,7 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', () test('the scroll fade only appears when the list really overflows', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); // A fade on a card where all five tiles fit promises a sixth mission that does // not exist, which is worse than no affordance at all. assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/); @@ -551,7 +552,7 @@ test('the decision table is written down where the next editor will read it', () test('markup, startup ordering and accessibility remain pinned', () => { const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/); assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4); @@ -631,7 +632,7 @@ test('the launcher keeps focus, restores it, and never disables the focused butt }); test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => { - const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8'); // The rail opened by default to advertise HUD / DETECT / 3D. Those default ON // now, so it was opening to offer controls for things already happening — // while competing with the mission card for the one first impression there is. diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs index 3e6cfa541..5244cffdc 100644 --- a/src/keyboardFocusStyles.test.mjs +++ b/src/keyboardFocusStyles.test.mjs @@ -1,8 +1,9 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); function ruleBody(selector) { const start = css.indexOf(selector); diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 7537efb06..cb26795e6 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -89,15 +90,15 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const start = ui.indexOf(' _handleShareTrackingRestoreStatus(result) {'); const end = ui.indexOf('\n _initGlobalContextPanel() {', start); const handler = ui.slice(start, end); assert.match(handler, /this\._showGlobalStatusNotice\(message\)/); assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /requestAnimationFrame\(\(\) => \{/); - assert.match(handler, /startupCover\.addEventListener\('transitionend', showOnce, \{ once: true \}\)/); - assert.match(handler, /fallbackTimer = setTimeout\(showOnce, 1000\)/); + assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(handler, /this\._lifetime\.listen\(startupCover, 'transitionend', showOnce, \{ once: true \}\)/); + assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); assert.doesNotMatch(handler, /this\._showToast\(message\)/); assert.doesNotMatch(handler, /pushCockpitSignal/); assert.match(handler, /result\.classification === 'pending'/); @@ -195,13 +196,13 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed }); test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const disposeStart = ui.indexOf(' async dispose() {'); const disposeEnd = ui.indexOf('\n }\n', disposeStart); const dispose = ui.slice(disposeStart, disposeEnd); - assert.match(dispose, /this\._globalStatusNotice = null;/); + assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/); assert.match(dispose, /this\._shareTrackingNoticeGeneration \+= 1;/); assert.match(html, /