Compare commits

...
Author SHA1 Message Date
Sameh Khamis 2cac463c6f Changelog: CCTV opening camera 2026-11-07 03:52:54 -08:00
Sameh Khamis 479286a6a6 CCTV opens on the camera nearest the viewer, not the first in the catalog
Turning CCTV on picked the first catalog record, so at King's Cross it
showed a Sydney camera. enable() now picks the camera nearest the viewer.
Until someone chooses a camera, each settled move re-picks the nearest,
so a share link whose flight lands after CCTV turned on still opens
nearby.
2026-11-07 03:52:38 -08:00
10 changed files with 173 additions and 7 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
- Turning CCTV on opens the camera nearest you instead of the first camera in
the catalog, which showed a Sydney camera in London. A share link opens on
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
+2
View File
@@ -67,6 +67,8 @@ export const _extractPickedCameraIdForTest =
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest; export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord; export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop; export const maybeAutoHop = layer.maybeAutoHop;
export const openingCameraId = layer.openingCameraId;
export const reopenNearestCamera = layer.reopenNearestCamera;
export const cctvCycleIndex = layer.cctvCycleIndex; export const cctvCycleIndex = layer.cctvCycleIndex;
export { export {
CCTV_CALIBRATION_STORAGE_KEY_V1, CCTV_CALIBRATION_STORAGE_KEY_V1,
+116
View File
@@ -61,6 +61,8 @@ import cctvLayer, {
materializeCctvActiveCoverageEntities, materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities, materializeCctvVisibleCoverageEntities,
maybeAutoHop, maybeAutoHop,
openingCameraId,
reopenNearestCamera,
prioritizeActiveCctvGeometryRecord, prioritizeActiveCctvGeometryRecord,
processCctvGeometryDrainBatch, processCctvGeometryDrainBatch,
processCctvGeometryQueueBatch, processCctvGeometryQueueBatch,
@@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
} }
}); });
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
// the first catalog record, instead of the TfL camera 0.3 km away.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(openingCameraId(), 'gb-kings-cross');
_setCctvCoverageStateForTest({
viewer: null,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(
openingCameraId(),
'au-sydney',
'with no viewer position the first record is the fallback',
);
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
assert.equal(openingCameraId(), null);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
// A share link turns CCTV on before its flight lands, so enable() picks
// from the startup view; the flight's settled move re-picks near London.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
for (const record of [sydney, london])
Object.assign(record, {
groundResolved: {},
groundSamples: {},
groundPrior: 0,
frustumGeometry: null,
frustumPositions: null,
probeClampRangeM: null,
});
_setCctvOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
// Activation builds the projection canvas and frame image.
const originalDocument = globalThis.document;
const originalImage = globalThis.Image;
globalThis.document = {
createElement: () => ({ getContext: () => null }),
};
globalThis.Image = class {};
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
assert.equal(reopenNearestCamera(), true);
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
assert.equal(reopenNearestCamera(), false, 'already the nearest');
// Someone chooses a camera: settled moves leave it alone.
setActiveCamera('au-sydney');
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
// A deselect is a choice too.
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
deactivateActiveCamera();
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, null);
} finally {
globalThis.document = originalDocument;
globalThis.Image = originalImage;
_setCctvOverlayHostForTest();
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => { test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
const calls = []; const calls = [];
_setCctvOverlayHostForTest({ _setCctvOverlayHostForTest({
+1 -1
View File
@@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => {
const v = viewer(t), empty = new Proxy({}, { get: () => noop }); const v = viewer(t), empty = new Proxy({}, { get: () => noop });
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost }, const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty }, services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} }); parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} });
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable(); lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
}); });
+2
View File
@@ -112,6 +112,8 @@ export function createCctvLayer({ services, source }) {
_setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest, _setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest,
focusCctvRecord: parts.navigation.focusCctvRecord, focusCctvRecord: parts.navigation.focusCctvRecord,
maybeAutoHop: parts.navigation.maybeAutoHop, maybeAutoHop: parts.navigation.maybeAutoHop,
openingCameraId: parts.navigation.openingCameraId,
reopenNearestCamera: parts.navigation.reopenNearestCamera,
cctvCycleIndex: parts.navigation.cctvCycleIndex, cctvCycleIndex: parts.navigation.cctvCycleIndex,
}, },
); );
+7 -6
View File
@@ -151,6 +151,7 @@ export function createLifecycle({
layerState._enabled = false; layerState._enabled = false;
layerState._activeCameraId = null; layerState._activeCameraId = null;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
layerState._lastHopAt = 0; layerState._lastHopAt = 0;
layerState._lastViewContext = ''; layerState._lastViewContext = '';
layerState._calibrationById = parts.calibration.loadCalibrationStore(); layerState._calibrationById = parts.calibration.loadCalibrationStore();
@@ -299,11 +300,8 @@ export function createLifecycle({
} }
layerState._count = layerState._records.length; layerState._count = layerState._records.length;
if (layerState._records.length > 0) { // No camera is picked here: enable() opens on the one nearest the
// Projection runtime + first frame fetch are deferred to enable() so // viewer, unless restored params selected one first.
// initializing the catalog stays render-cheap.
layerState._activeCameraId = layerState._records[0].camera.id;
}
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc // Task 5: if the prior batch lost init's bounded race, apply it post-hoc
// when it lands (pure recomputes — applyLateGroundPriors guards against // when it lands (pure recomputes — applyLateGroundPriors guards against
@@ -361,6 +359,7 @@ export function createLifecycle({
layerState._horizonCullListener = () => { layerState._horizonCullListener = () => {
layerState._cameraMoving = false; layerState._cameraMoving = false;
stopViewMotionTimer(); stopViewMotionTimer();
parts.navigation.reopenNearestCamera();
settleView(); settleView();
}; };
layerState._viewer.camera.moveEnd.addEventListener( layerState._viewer.camera.moveEnd.addEventListener(
@@ -508,8 +507,9 @@ export function createLifecycle({
return Boolean(coverage && layerState._recordById.has(coverage[1])); return Boolean(coverage && layerState._recordById.has(coverage[1]));
}); });
if (!layerState._activeCameraId && layerState._records.length) { if (!layerState._activeCameraId && layerState._records.length) {
layerState._activeCameraId = layerState._records[0].camera.id; layerState._activeCameraId = parts.navigation.openingCameraId();
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = true;
} }
const activeRecord = parts.selection.getActiveRecord(); const activeRecord = parts.selection.getActiveRecord();
if (activeRecord) { if (activeRecord) {
@@ -629,6 +629,7 @@ export function createLifecycle({
layerState._enabled = false; layerState._enabled = false;
layerState._activeCameraId = null; layerState._activeCameraId = null;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// Clear existing subscribers rather than replacing the Set — // Clear existing subscribers rather than replacing the Set —
// replacing would silently orphan any unsubscribe() closures // replacing would silently orphan any unsubscribe() closures
layerState._listeners.clear(); layerState._listeners.clear();
+32
View File
@@ -33,6 +33,36 @@ export function createNavigation({
return best?.id || null; return best?.id || null;
} }
/**
* The camera the layer opens on when none is selected: the one nearest the
* viewer, or the first record when the viewer has no position yet.
* @returns {string|null} Camera ID, or null when the catalog is empty.
*/
function openingCameraId() {
return (
nearestCameraIdToViewer() || layerState._records[0]?.camera?.id || null
);
}
/**
* After a settled move, moves the opening camera to the one nearest the
* view, until someone chooses a camera. A shared view's flight lands after
* enable() picked from the startup view.
* @returns {boolean} True when the active camera changed.
*/
function reopenNearestCamera() {
if (!layerState._openingCameraProvisional || !layerState._enabled)
return false;
const nearest = nearestCameraIdToViewer();
if (!nearest || nearest === layerState._activeCameraId) return false;
parts.selection.setActiveCamera(nearest);
// setActiveCamera ends the opening pick; this is still that pick.
layerState._openingCameraProvisional = true;
return true;
}
/** /**
* Flies the Cesium viewer camera to frame the specified CCTV camera, * Flies the Cesium viewer camera to frame the specified CCTV camera,
* looking along its heading from above. * looking along its heading from above.
@@ -149,6 +179,8 @@ export function createNavigation({
} }
return { return {
nearestCameraIdToViewer, nearestCameraIdToViewer,
openingCameraId,
reopenNearestCamera,
focusCctvRecord, focusCctvRecord,
focusCamera, focusCamera,
maybeAutoHop, maybeAutoHop,
+2
View File
@@ -83,6 +83,7 @@ export function createSelection({
} }
layerState._activeCameraId = cameraId; layerState._activeCameraId = cameraId;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// A real activation creates projection work — wake the self-stopping loop. // A real activation creates projection work — wake the self-stopping loop.
parts.projection.startProjectionLoop(); parts.projection.startProjectionLoop();
if (previousActiveRecord && previousActiveRecord !== record) { if (previousActiveRecord && previousActiveRecord !== record) {
@@ -169,6 +170,7 @@ export function createSelection({
if (!record) return false; if (!record) return false;
layerState._activeCameraId = null; layerState._activeCameraId = null;
layerState._autoHopSuspended = true; layerState._autoHopSuspended = true;
layerState._openingCameraProvisional = false;
record.activationDone = false; record.activationDone = false;
parts.geometry.clearProbeClampOnDeactivation(record, (previous) => { parts.geometry.clearProbeClampOnDeactivation(record, (previous) => {
parts.geometry.applyFrustumGeometry( parts.geometry.applyFrustumGeometry(
+6
View File
@@ -36,6 +36,12 @@ export function createState({ services }) {
layerState._autoHop = false; layerState._autoHop = false;
// True while the active camera is the one enable() opened on and nobody has
// chosen a camera since: each settled move then re-picks the one nearest
// the view, so a share flight that lands after enable still opens nearby.
layerState._openingCameraProvisional = false;
// An explicit empty-space deselect keeps AUTO HOP configured but prevents its // An explicit empty-space deselect keeps AUTO HOP configured but prevents its
// timer from silently choosing a replacement. A later explicit activation or // timer from silently choosing a replacement. A later explicit activation or
// AUTO HOP toggle-on releases the hold. // AUTO HOP toggle-on releases the hold.
+2
View File
@@ -91,6 +91,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
coverageMode = 'on', coverageMode = 'on',
showProjection = false, showProjection = false,
healthById = new Map(), healthById = new Map(),
openingCameraProvisional = false,
} = {}) { } = {}) {
layerState._viewer = viewer; layerState._viewer = viewer;
layerState._records = Array.isArray(records) ? records : []; layerState._records = Array.isArray(records) ? records : [];
@@ -104,6 +105,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
layerState._billboards = null; layerState._billboards = null;
layerState._activeCameraId = activeCameraId; layerState._activeCameraId = activeCameraId;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = !!openingCameraProvisional;
layerState._enabled = !!enabled; layerState._enabled = !!enabled;
layerState._coverageMode = parts.model.normalizeCoverageMode( layerState._coverageMode = parts.model.normalizeCoverageMode(
coverageMode, coverageMode,