mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 11:54:02 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cac463c6f | ||
|
|
479286a6a6 |
No files matched your search
@@ -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).
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user