Compare commits

..
Author SHA1 Message Date
Said Turksever f29b0b2639 Keep the CCTV panel through Context isolation, and collapse through the panel chrome
Contacts and Space Missions switch CCTV off to isolate themselves and restore
it on the way out: the panel and its share preference now stay as they are for
that round trip, as they do for Clear selected data layers. The controls no
longer read the collapsed class before asking: the panel chrome tells a panel
the accordion folded from one whose saved state is closed.

The camera controls gate asserts the collapse, reopens CCTV from the right
rail before switching it back on, and runs a Contacts entry and exit with the
panel open.
2026-11-13 17:46:43 +00:00
Said Turksever 7f18943ec4 docs: CCTV panel collapse on switch-off, and changelog fragment 2026-11-13 17:27:22 +00:00
Said Turksever 0325805eb0 Collapse the CCTV panel when the layer is switched off
The panel opened itself when a camera became active, but nothing closed it
when the layer went off: it stayed expanded, empty, and its meta line still
described the last camera. The collapse runs only on the on-to-off change, so a
panel opened by hand while the layer is off stays open, and a context-mode
layer clear keeps the panel as Radio does.
2026-11-13 17:26:34 +00:00
10 changed files with 254 additions and 1 deletions

No files matched your search

@@ -0,0 +1,5 @@
- Collapse the CCTV panel when the layer is switched off. It stayed open with
an empty frame and a meta line that still described the last camera; the
meta line of a disabled layer now reads the catalog count. Contacts, Space
Missions and Clear selected data layers leave the panel as it is
(asturksever, #1723).
+9
View File
@@ -2229,6 +2229,15 @@ shown without a fade once it is decoded (or after one second if its decode never
settles), so a new camera's preview is blank only while its frame loads. Disposal
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
The panel opens when a camera becomes active and collapses when the layer is
switched off, from its own switch or anywhere else. The panel chrome applies
the collapse, so a panel the desktop accordion only folded has its saved and
shared state closed too. Clear selected data layers and Context isolation
(Contacts, Space Missions) switch the layer off without the user asking: the
panel and its share preference stay as they are, and the layer comes back to an
open panel. A panel opened by hand while the layer is off stays open, and its
meta line reads the catalog count, not the last active camera.
## UI disposal
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
+75
View File
@@ -339,6 +339,45 @@ try {
'#cctv-frame',
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
);
// The address bar follows the share state after a short delay: wait for the
// CCTV panel token (1 collapsed, 0 open) the step expects.
const panelState = async (expectedToken) => {
const settled = await page
.waitForFunction(
(token) =>
new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test(
new URLSearchParams(location.hash.slice(1)).get('ui') || '',
),
{ timeout: 5000 },
expectedToken,
)
.then(
() => true,
() => false,
);
return page.evaluate(
(shared) => ({
collapsed: document
.getElementById('cctv-panel')
.classList.contains('collapsed'),
shared,
ui: new URLSearchParams(location.hash.slice(1)).get('ui'),
}),
settled ? expectedToken : null,
);
};
const closedOnDisable = await panelState('1');
check(
'disable collapses the panel and its share preference',
closedOnDisable.collapsed && closedOnDisable.shared === '1',
JSON.stringify(closedOnDisable),
);
// The switch sits inside the collapsed panel: reopen it from the right rail.
await page.click('[data-rail-item="cctv-panel"]');
await page.waitForFunction(
() =>
!document.getElementById('cctv-panel').classList.contains('collapsed'),
);
await page.click('#cctv-enable-btn');
await page.waitForFunction(
() =>
@@ -351,6 +390,42 @@ try {
'disable clears the preview and re-enable reacquires the camera frame',
disabledCleanly,
);
// Contacts switches CCTV off to isolate itself and restores it on the way
// out: the open panel and its share preference survive the round trip.
const cameraBeforeContacts = await page.evaluate(
() =>
window.__godsEyeView.styleManager._cctvControls.getState()
?.activeCameraId,
);
const entered = await page.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode('flights'),
);
await page.waitForFunction(
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const duringContacts = await panelState('0');
await page.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode(null),
);
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const afterContacts = await panelState('0');
check(
'Contacts isolation keeps the open CCTV panel and restores its camera',
entered?.ok === true &&
!duringContacts.collapsed &&
duringContacts.shared === '0' &&
!afterContacts.collapsed &&
afterContacts.shared === '0' &&
(await page.evaluate(
(cameraId) =>
window.__godsEyeView.styleManager._cctvControls.getState()
?.activeCameraId === cameraId,
cameraBeforeContacts,
)),
JSON.stringify({ entered, duringContacts, afterContacts }),
);
await page.$eval('#cctv-frame', (image) =>
image.scrollIntoView({ block: 'nearest' }),
);
+5
View File
@@ -1197,6 +1197,11 @@ export class StyleManager extends ShellFacade {
toggleEnabled: (...args) => this._toggleCctvEnabled(...args),
runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
// Clear selected data layers and Context isolation switch the layer
// off without the user asking: the panel keeps its state for both.
preservePanelState: () =>
this._preservePanelStateDuringLayerClear ||
this._contextSuspendsLayer('cctv'),
showToast: (message) => this._showToast(message),
syncViewport: () => this._syncCctvPanelViewport(),
},
+1
View File
@@ -54,6 +54,7 @@ export class CctvControls {
this._cctvSummaryTypingTimer = null;
this._lastCctvSummaryText = '';
this._lastSeenCctvActiveId = null;
this._wasCctvEnabled = false;
this._cctvChipHideTimer = null;
this._cctvChipWasBusy = false;
// The camera grid chip as last published; its label stays while hidden.
+93
View File
@@ -513,3 +513,96 @@ test('the panel meta line reads a bearing that rounds to north as HDG 0°', () =
assert.match(meta(359.4), /· HDG 359° ·/);
assert.match(meta(359.6), /· HDG 0° ·/);
});
/** The panel collapses the test asked for, as `[panelId, collapsed]` pairs. */
function collapseCalls(controls) {
const calls = [];
controls.actions.setPanelCollapsed = (panelId, collapsed) => {
calls.push([panelId, collapsed]);
// The panel chrome owns the class the controls read back.
controls._cctvPanel.classList.toggle('collapsed', collapsed);
};
return calls;
}
const activeState = {
enabled: true,
count: 1,
cameraIndex: [],
activeCameraId: camera.id,
activeCamera: camera,
};
test('switching the layer off collapses the panel it opened', (t) => {
const { controls } = panel(t);
const calls = collapseCalls(controls);
controls._renderCctvState(activeState);
assert.deepEqual(calls, [['cctv-panel', false]]);
// The layer keeps its active camera while off.
controls._renderCctvState({ ...activeState, enabled: false });
assert.deepEqual(calls.at(-1), ['cctv-panel', true]);
// A panel reopened by hand stays open through later notifications while
// the layer is off: only the on → off change collapses.
controls._cctvPanel.classList.remove('collapsed');
controls._renderCctvState({ ...activeState, enabled: false });
assert.equal(calls.length, 2);
assert.equal(controls._cctvPanel.classList.contains('collapsed'), false);
});
test('a layer that was never on leaves an open panel alone', (t) => {
const { controls } = panel(t);
const calls = collapseCalls(controls);
controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] });
controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] });
assert.deepEqual(calls, []);
});
test('the data manager switching the layer off collapses the panel too', (t) => {
const { controls } = panel(t);
const calls = collapseCalls(controls);
let on = true;
controls.actions.isEnabled = () => on;
controls._renderCctvState(activeState);
on = false;
controls._renderCctvState(activeState);
assert.deepEqual(calls.at(-1), ['cctv-panel', true]);
});
test('a layer the application suspends keeps its panel through the round trip', (t) => {
const { controls } = panel(t);
const calls = collapseCalls(controls);
// Clear selected data layers and Context isolation both answer true.
let suspended = true;
controls.actions.preservePanelState = () => suspended;
controls._renderCctvState(activeState);
controls._renderCctvState({ ...activeState, enabled: false });
assert.equal(controls._cctvPanel.classList.contains('collapsed'), false);
suspended = false;
controls._renderCctvState(activeState);
assert.equal(controls._cctvPanel.classList.contains('collapsed'), false);
assert.ok(calls.every(([, collapsed]) => collapsed === false));
// The next switch-off is the user's again.
controls._renderCctvState({ ...activeState, enabled: false });
assert.deepEqual(calls.at(-1), ['cctv-panel', true]);
});
test('a panel the accordion folded still has its saved state closed', (t) => {
const { controls } = panel(t);
controls._renderCctvState(activeState);
const calls = [];
controls.actions.setPanelCollapsed = (...args) => calls.push(args);
// Folded for layout only: the class is there, the saved state is open. The
// panel chrome tells the two apart, so the controls always ask it.
controls._cctvPanel.classList.add('collapsed');
controls._renderCctvState({ ...activeState, enabled: false });
assert.deepEqual(calls, [['cctv-panel', true]]);
});
test('the meta line of a disabled layer does not describe its last camera', () => {
const off = { ...activeState, enabled: false, count: 3 };
assert.equal(
presentCctvPanel(off, false).meta,
'3 cameras loaded · enable CCTV to activate',
);
assert.match(presentCctvPanel(activeState, true).meta, /^Austin · HDG /);
});
+17 -1
View File
@@ -90,7 +90,9 @@ export function presentCctvPanel(state, enabled) {
}
function metaText(state, enabled, activeCamera, catalogCount) {
if (activeCamera) {
// The layer keeps its active camera while off: a disabled panel must not
// describe a feed it no longer shows.
if (enabled && activeCamera) {
const provider = cctvSourceLabelText(
activeCamera.sourceLabel || activeCamera.provider || 'Configured Source',
);
@@ -188,6 +190,20 @@ export function _renderCctvState(state) {
});
}
this._lastSeenCctvActiveId = effectiveActiveId;
// Switching the layer off closes the panel it opened: nothing is left to
// show. Only the on → off change collapses, so a panel the user opens
// while the layer is off stays open. Clear selected data layers and
// Context isolation keep the panel: the layer comes back with it. The
// panel chrome decides whether anything changes: a panel the desktop
// accordion only folded still has an open saved state to close.
if (
this._wasCctvEnabled &&
!enabled &&
!this.actions.preservePanelState?.()
) {
this.actions.setPanelCollapsed('cctv-panel', true);
}
this._wasCctvEnabled = enabled;
this._updateCctvSyncChip(state?.loading, enabled);
+9
View File
@@ -59,6 +59,15 @@ export class ContextControls {
showToast(...args) {
if (!this.destroyed) this.actions.showToast(...args);
}
/**
* Whether Context isolation switched this layer off and leaving the mode
* restores it. Such a layer is suspended, not switched off by the user.
* @param {string} layerId Layer id.
* @returns {boolean}
*/
suspendsLayer(layerId) {
return Boolean(this._contextSessionSnapshot?.enabledLayerIds?.has(layerId));
}
setClearBusy(...args) {
if (!this.destroyed) this.actions.setClearBusy(...args);
}
+36
View File
@@ -153,3 +153,39 @@ test('a rejected tracked reaction settles without an unhandled rejection', async
await controls._waitForContextLayerSettlement();
assert.equal(controls._contextLayerReactionPromises.size, 0);
});
test('a layer Context isolation switches off is suspended until the mode is left', async (t) => {
const { controls } = fixture(t);
const enabled = new Set(['cctv']);
// What the CCTV panel is told at each visibility change of its layer.
const seen = [];
controls._dataManager = {
layers: new Map([['cctv', {}]]),
getEnabledLayerIds: () => [...enabled],
getLayerParams: () => null,
setLayerParams() {},
isEnabled: (layerId) => enabled.has(layerId),
isEffectivelyEnabled: (layerId) => enabled.has(layerId),
async setEnabled(layerId, next) {
if (next) enabled.add(layerId);
else enabled.delete(layerId);
seen.push([layerId, next, controls.suspendsLayer(layerId)]);
return true;
},
async restoreEnabledLayerIds(layerIds) {
for (const layerId of layerIds) await this.setEnabled(layerId, true);
},
};
assert.equal(controls.suspendsLayer('cctv'), false);
controls._captureContextSessionSnapshot();
await controls._clearLayersOutsideContextMode('flights');
assert.deepEqual(seen, [['cctv', false, true]]);
assert.equal(controls.suspendsLayer('cctv'), true);
assert.equal(controls.suspendsLayer('street-level'), false);
await controls._restoreContextSession();
assert.equal(enabled.has('cctv'), true);
assert.equal(controls.suspendsLayer('cctv'), false);
// A switch-off after the mode is left is the user's own.
await controls._dataManager.setEnabled('cctv', false);
assert.deepEqual(seen.at(-1), ['cctv', false, false]);
});
+4
View File
@@ -891,6 +891,10 @@ export class ShellFacade {
return this._contextControls?._preservePanelStateDuringLayerClear ?? false;
}
_contextSuspendsLayer(layerId) {
return this._contextControls?.suspendsLayer(layerId) ?? false;
}
_runUserFacingContextAction(...args) {
return this._contextControls?._runUserFacingContextAction(...args);
}