Merge pull request #400 from bilawalsidhu/refactor/camera-panel

refactor: separate camera panel controls and presentation
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-13 01:26:53 -07:00
commit 7f70b97536
20 files changed
+1609 -667

No files matched your search

+2
View File
@@ -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.
+7
View File
@@ -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.
+11
View File
@@ -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
+2 -1
View File
@@ -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"
}
}
+9 -1
View File
@@ -173,5 +173,13 @@
"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",
"scripts/qa-camera-controls.mjs"
]
+12
View File
@@ -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": []
}
}
+413
View File
@@ -0,0 +1,413 @@
#!/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: `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${cameraA ? '#16364d' : '#254b32'}"/><path d="M0 260L640 130M240 0L420 360" stroke="#97acb7" stroke-width="26"/><text x="30" y="55" fill="white" font-size="30">QA CAMERA ${cameraA ? 'A' : 'B'}</text></svg>`,
})
.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;
}),
);
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',
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;
+3 -1
View File
@@ -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');
+30 -6
View File
@@ -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,
+8 -5
View File
@@ -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,13 @@ 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 +112,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);
+22
View File
@@ -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'\)/);
});
+4 -4
View File
@@ -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();
+46 -649
View File
@@ -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);
@@ -5091,351 +4977,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 <img> 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 +5029,7 @@ export class StyleManager {
* @returns {Promise<boolean>} 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 +5044,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 +5055,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 +7906,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 +7993,6 @@ export class StyleManager {
this._dataManagerUnsubscribe?.();
this._dataManagerUnsubscribe = null;
this._cctvUnsubscribe?.();
this._cctvUnsubscribe = null;
this._commandDockTrayObserver?.disconnect?.();
this._commandDockTrayObserver = null;
this._draggableResizeObserver?.disconnect();
+1
View File
@@ -0,0 +1 @@
export { CctvControls } from './cctvControls.js';
+155
View File
@@ -0,0 +1,155 @@
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();
}
+185
View File
@@ -0,0 +1,185 @@
/** 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;
}
}
+114
View File
@@ -0,0 +1,114 @@
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');
}
}
+220
View File
@@ -0,0 +1,220 @@
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);
});
+111
View File
@@ -0,0 +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');
}
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';
}
+254
View File
@@ -0,0 +1,254 @@
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');
}
}