diff --git a/changelog.d/1485-cctv-view-thinning.md b/changelog.d/1485-cctv-view-thinning.md new file mode 100644 index 000000000..307da84ad --- /dev/null +++ b/changelog.d/1485-cctv-view-thinning.md @@ -0,0 +1,4 @@ +- Draw only the CCTV cameras in view, and thin them to one per ground cell when + zoomed out. Dragging the globe with the default 4,000-camera catalog no + longer stalls, and the camera picker lists the cameras in view with a count + of the ones outside it (samehkhamis, #1485). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0492c7c24..26f8178ed 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -978,6 +978,15 @@ directory. Every `/api/cctv` route refuses cross-site browser requests but accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded request skips the key-bearing Street View fallback and gets the placeholder. +The CCTV layer draws only the cameras on or near the screen. Zoomed out, it +keeps one camera per ground cell sized to the view, preferring a healthy feed, +and the cells are anchored to the ground so a pan keeps the same cameras. The +active camera always shows. Ground placement and coverage geometry load only +for the cameras in view, with at most two attempts per camera and view regime. +The view pass reruns while the camera moves, paced by its own cost. The camera +picker lists the cameras in view, active camera first, and closes with the +count of cameras outside the view. + Traffic owns its rendered-surface observer only while enabled, removes it on disable/destroy, and caches surface identity without allocating on unchanged frames. Traffic and bikeshare expose factories through `./layers/traffic` and diff --git a/src/data/cctv.js b/src/data/cctv.js index da65f5f08..ad95f2794 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord = export const processGeometryBatch = layer.processGeometryBatch; export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals; export const refreshCoverageStyles = layer.refreshCoverageStyles; +export const refreshHorizonCulling = layer.refreshHorizonCulling; +export const _viewRecordIdsForTest = layer._viewRecordIdsForTest; export const clearProbeClampOnDeactivation = layer.clearProbeClampOnDeactivation; export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation; diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index dafaeb5f6..0b7cee20f 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -1309,11 +1309,12 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones assert.ok(renderer, '_renderCctvState is missing'); assert.match( renderer[0], - /else if \(!activeId\)[\s\S]*?selectedIndex = -1/, + // Nothing active clears the pick; only the outside-view note may show. + /else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/, ); assert.match( renderer[0], - /_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/, + /_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/, ); } finally { cctvLayer.setParams({ autoHop: false }); diff --git a/src/data/cctvViewThinning.test.mjs b/src/data/cctvViewThinning.test.mjs new file mode 100644 index 000000000..1bc94eed8 --- /dev/null +++ b/src/data/cctvViewThinning.test.mjs @@ -0,0 +1,251 @@ +// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or +// near the screen show, a zoomed-out view keeps one camera per world-anchored +// cell, and the layer's UI state never rebuilds every camera for a listener +// that does not read the list. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import cctvLayer, { + _setCctvCoverageStateForTest, + _viewRecordIdsForTest, + refreshHorizonCulling, +} from './cctv.js'; + +const WIDTH = 800; +const HEIGHT = 600; + +/** + * Records at the given [lon, lat] degrees near the origin, each drawn at the + * screen point `screenAt` gives it (default: the middle of the screen). + */ +function recordsAt( + lonLats, + screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }), +) { + const screenByPosition = new Map(); + const records = lonLats.map(([lon, lat], index) => { + const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10); + screenByPosition.set(position, screenAt(index)); + return { + camera: { + id: `cam-${index}`, + name: `Camera ${index}`, + city: 'Test', + lat, + lon, + headingDeg: 0, + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + }, + position, + billboard: { position, show: true }, + coverageEntities: [], + projection: null, + }; + }); + return { records, screenByPosition }; +} + +function viewerAt(heightM, screenByPosition, shiftX = 0) { + return { + isDestroyed: () => false, + camera: { + positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM), + positionCartographic: { latitude: 0, longitude: 0, height: heightM }, + frustum: { fovy: Math.PI / 3 }, + }, + scene: { + canvas: { clientWidth: WIDTH, clientHeight: HEIGHT }, + cartesianToCanvasCoordinates: (position) => { + const screen = screenByPosition.get(position); + return screen && { x: screen.x + shiftX, y: screen.y }; + }, + }, + }; +} + +function shownIds(records) { + return records + .filter((record) => record.billboard.show) + .map((record) => record.camera.id) + .sort(); +} + +// Three cameras within ~110 m, and one ~22 km away. +const CLUSTER = [ + [0, 0], + [0.001, 0], + [0.0005, 0.0005], + [0.2, 0], +]; + +test('zoomed out, cameras sharing a ground cell show one icon', () => { + const { records, screenByPosition } = recordsAt(CLUSTER); + _setCctvCoverageStateForTest({ + viewer: viewerAt(20_000, screenByPosition), + records, + }); + assert.equal( + refreshHorizonCulling(), + true, + 'the first pass changes the view', + ); + const shown = shownIds(records); + assert.equal(shown.length, 2, 'one for the cluster, one for the far camera'); + assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows'); + assert.deepEqual(_viewRecordIdsForTest().sort(), shown); +}); + +test('a pan keeps the same winners, and an unchanged view keeps its list', () => { + const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({ + x: 100 + index * 7, + y: 300, + })); + _setCctvCoverageStateForTest({ + viewer: viewerAt(20_000, screenByPosition), + records, + }); + refreshHorizonCulling(); + const before = shownIds(records); + const list = _viewRecordIdsForTest(); + for (const shiftX of [3, 11, 20, 37]) { + _setCctvCoverageStateForTest({ + viewer: viewerAt(20_000, screenByPosition, shiftX), + records, + }); + refreshHorizonCulling(); + assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`); + } + assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing'); + assert.deepEqual(_viewRecordIdsForTest(), list); +}); + +test('a small zoom within one cell size keeps the same winners', () => { + const { records, screenByPosition } = recordsAt(CLUSTER); + const shownAt = (heightM) => { + _setCctvCoverageStateForTest({ + viewer: viewerAt(heightM, screenByPosition), + records, + }); + refreshHorizonCulling(); + return shownIds(records); + }; + // Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at + // these heights fall in the same step, so the same cameras win. + assert.deepEqual(shownAt(20_500), shownAt(20_000)); +}); + +test('a healthy camera wins its cell over an unknown one', () => { + const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2)); + // Whichever camera the id order would pick, the healthy one must win. + for (const id of ['cam-0', 'cam-1']) { + _setCctvCoverageStateForTest({ + viewer: viewerAt(20_000, screenByPosition), + records, + healthById: new Map([[id, { status: 'ok' }]]), + }); + refreshHorizonCulling(); + assert.deepEqual(shownIds(records), [id]); + } +}); + +test('the active camera always shows, even in a crowded cell', () => { + const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3)); + for (const active of records) { + _setCctvCoverageStateForTest({ + viewer: viewerAt(20_000, screenByPosition), + records, + activeCameraId: active.camera.id, + }); + refreshHorizonCulling(); + assert.ok(active.billboard.show, `${active.camera.id} shows while active`); + } +}); + +test('close up, every camera near the screen shows and far-off ones hide', () => { + const { records, screenByPosition } = recordsAt( + [ + [0, 0], + [0.0001, 0], + [0.0002, 0], + ], + (index) => + index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 }, + ); + _setCctvCoverageStateForTest({ + viewer: viewerAt(300, screenByPosition), + records, + }); + refreshHorizonCulling(); + assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']); +}); + +test('a disabled layer skips the view pass', () => { + const { records, screenByPosition } = recordsAt(CLUSTER); + _setCctvCoverageStateForTest({ + viewer: viewerAt(20_000, screenByPosition), + records, + enabled: false, + }); + assert.equal(refreshHorizonCulling(), false); + assert.deepEqual(_viewRecordIdsForTest(), []); +}); + +test('UI state builds the full camera list only when it is read', () => { + const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2)); + _setCctvCoverageStateForTest({ + viewer: viewerAt(300, screenByPosition), + records, + }); + refreshHorizonCulling(); + let built = 0; + for (const record of records) { + const camera = record.camera; + record.camera = new Proxy(camera, { + get(target, key) { + if (key === 'calibration') built += 1; + return target[key]; + }, + }); + } + const first = cctvLayer.getUIState(); + assert.equal(built, 0, 'listeners that skip `cameras` cost nothing'); + assert.deepEqual( + first.cameraIndex.map((camera) => camera.id), + ['cam-0', 'cam-1'], + ); + assert.equal( + cctvLayer.getUIState().cameraIndex, + first.cameraIndex, + 'the index keeps its identity while the view does', + ); + assert.equal(first.cameras.length, 2); + assert.ok(built > 0, 'reading `cameras` builds it'); + assert.equal(first.cameras, first.cameras, 'and builds it once'); +}); + +test('the camera picker lists the view, with the active camera first', () => { + const { records, screenByPosition } = recordsAt( + [ + [0, 0], + [0.0001, 0], + [0.0002, 0], + ], + (index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }), + ); + _setCctvCoverageStateForTest({ + viewer: viewerAt(300, screenByPosition), + records, + activeCameraId: 'cam-1', + }); + refreshHorizonCulling(); + const state = cctvLayer.getUIState(); + assert.deepEqual( + state.cameraIndex.map((camera) => camera.id), + ['cam-1', 'cam-0', 'cam-2'], + 'the off-screen active camera leads, then what is in view', + ); + assert.equal(state.cameras.length, 3, 'the full list is still every camera'); +}); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 874fd5f31..8e383aded 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -372,6 +372,10 @@ "one": "{count} Kamera geladen · CCTV aktivieren", "other": "{count} Kameras geladen · CCTV aktivieren" }, + "{count} more cameras outside this view · pan or zoom out": { + "one": "{count} weitere Kamera außerhalb dieser Ansicht · verschieben oder herauszoomen", + "other": "{count} weitere Kameras außerhalb dieser Ansicht · verschieben oder herauszoomen" + }, "Enable CCTV to load camera intersections": "CCTV aktivieren, um Kreuzungskameras zu laden", "Enable CCTV to start camera-linked intelligence summaries.": "CCTV aktivieren, um kameragestützte Lagezusammenfassungen zu starten.", "No summary available.": "Keine Zusammenfassung verfügbar.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0ae48f38b..c66eae636 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -369,6 +369,11 @@ "many": "{count} de cámaras cargadas · activa CCTV para activarlas", "other": "{count} cámaras cargadas · activa CCTV para activarlas" }, + "{count} more cameras outside this view · pan or zoom out": { + "one": "{count} cámara más fuera de esta vista · desplaza o aleja", + "many": "{count} de cámaras más fuera de esta vista · desplaza o aleja", + "other": "{count} cámaras más fuera de esta vista · desplaza o aleja" + }, "No summary available.": "No hay resumen disponible.", "Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal", "Detection overlay: {mode}": "Superposición de detección: {mode}", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 005a22417..931e7c3ff 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1625,6 +1625,11 @@ "many": "{count} de caméras chargées · activer CCTV pour les utiliser", "other": "{count} caméras chargées · activer CCTV pour les utiliser" }, + "{count} more cameras outside this view · pan or zoom out": { + "one": "{count} autre caméra hors de cette vue · déplacer ou dézoomer", + "many": "{count} d'autres caméras hors de cette vue · déplacer ou dézoomer", + "other": "{count} autres caméras hors de cette vue · déplacer ou dézoomer" + }, "Enable CCTV to load camera intersections": "Activez CCTV pour charger les carrefours équipés de caméras", "Enable CCTV to start camera-linked intelligence summaries.": "Activez CCTV pour lancer les résumés de renseignement liés aux caméras.", "No summary available.": "Aucun résumé disponible.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index bc9732d67..62c9931c1 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1625,6 +1625,11 @@ "many": "{count} di telecamere caricate · attiva CCTV per attivarle", "other": "{count} telecamere caricate · attiva CCTV per attivarle" }, + "{count} more cameras outside this view · pan or zoom out": { + "one": "Altra {count} telecamera fuori da questa vista · sposta o riduci lo zoom", + "many": "Altre {count} di telecamere fuori da questa vista · sposta o riduci lo zoom", + "other": "Altre {count} telecamere fuori da questa vista · sposta o riduci lo zoom" + }, "Enable CCTV to load camera intersections": "Attiva CCTV per caricare le telecamere agli incroci", "Enable CCTV to start camera-linked intelligence summaries.": "Attiva CCTV per avviare i riepiloghi intelligence collegati alle telecamere.", "No summary available.": "Nessun riepilogo disponibile.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7ac2840f6..5a48dc886 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1579,6 +1579,9 @@ "{count} cameras loaded · enable CCTV to activate": { "other": "カメラ {count}台を読込済み · CCTVを有効にして使用" }, + "{count} more cameras outside this view · pan or zoom out": { + "other": "この表示範囲外にさらに {count}台のカメラ · 移動またはズームアウト" + }, "Enable CCTV to load camera intersections": "CCTVを有効にすると、カメラのある交差点を読み込みます", "Enable CCTV to start camera-linked intelligence summaries.": "CCTVを有効にすると、カメラ連動のインテリジェンス概要を開始します。", "No summary available.": "利用できる概要はありません。", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5e9a9a3ff..de42b5aab 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1625,6 +1625,11 @@ "many": "{count} de câmeras carregadas · ative o CCTV para usar", "other": "{count} câmeras carregadas · ative o CCTV para usar" }, + "{count} more cameras outside this view · pan or zoom out": { + "one": "Mais {count} câmera fora desta vista · mova ou afaste o zoom", + "many": "Mais {count} de câmeras fora desta vista · mova ou afaste o zoom", + "other": "Mais {count} câmeras fora desta vista · mova ou afaste o zoom" + }, "Enable CCTV to load camera intersections": "Ative o CCTV para carregar os cruzamentos com câmeras", "Enable CCTV to start camera-linked intelligence summaries.": "Ative o CCTV para iniciar resumos de inteligência vinculados às câmeras.", "No summary available.": "Nenhum resumo disponível.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4839eaaba..3aa2197e9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -342,6 +342,12 @@ "many": "Загружено {count} камер · включите CCTV, чтобы активировать", "other": "Загружено {count} камеры · включите CCTV, чтобы активировать" }, + "{count} more cameras outside this view · pan or zoom out": { + "one": "Ещё {count} камера вне этого вида · сдвиньте или отдалите", + "few": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите", + "many": "Ещё {count} камер вне этого вида · сдвиньте или отдалите", + "other": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите" + }, "Enable CCTV to load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках", "Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.", "No summary available.": "Сводка недоступна.", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index f87454f1a..6041d339f 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1647,6 +1647,12 @@ "many": "{count} камер завантажено · увімкніть CCTV, щоб активувати", "other": "{count} камери завантажено · увімкніть CCTV, щоб активувати" }, + "{count} more cameras outside this view · pan or zoom out": { + "one": "Ще {count} камера поза цим видом · посуньте або віддаліть", + "few": "Ще {count} камери поза цим видом · посуньте або віддаліть", + "many": "Ще {count} камер поза цим видом · посуньте або віддаліть", + "other": "Ще {count} камери поза цим видом · посуньте або віддаліть" + }, "Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях", "Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.", "No summary available.": "Зведення недоступне.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5115ab7c9..5fb455546 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1553,6 +1553,9 @@ "{count} cameras loaded · enable CCTV to activate": { "other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活" }, + "{count} more cameras outside this view · pan or zoom out": { + "other": "此视图外还有 {count} 个摄像头 · 平移或缩小" + }, "Enable CCTV to load camera intersections": "启用 CCTV 以加载路口摄像头", "Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。", "No summary available.": "暂无摘要。", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c3b5ca610..a2bb4ffa0 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1543,6 +1543,9 @@ "{count} cameras loaded · enable CCTV to activate": { "other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活" }, + "{count} more cameras outside this view · pan or zoom out": { + "other": "此視圖外還有 {count} 個攝像頭 · 平移或縮小" + }, "Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭", "Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。", "No summary available.": "暫無摘要。", diff --git a/src/layers/cctv/cards.js b/src/layers/cctv/cards.js index 72f0b88a4..04b8fb86f 100644 --- a/src/layers/cctv/cards.js +++ b/src/layers/cctv/cards.js @@ -99,7 +99,8 @@ export function createCards({ state: layerState, services, parts, source }) { const candidates = []; const screenById = new Map(); - for (const record of layerState._records) { + // Cards go only on the icons the view pass shows (refreshHorizonCulling). + for (const record of layerState._viewRecords) { const id = record.camera.id; if (id === activeId || !record.position) continue; let inView = false; @@ -187,7 +188,14 @@ export function createCards({ state: layerState, services, parts, source }) { nowMs: Date.now(), cardLimit: budgets.cardLimit, }); - layerState._cardIds = new Set(retention.keepIds); + // Grace keeps a card briefly after it loses selection, but never on a + // camera whose icon the view pass has hidden. + const shownIds = new Set( + layerState._viewRecords.map((record) => record.camera.id), + ); + layerState._cardIds = new Set( + retention.keepIds.filter((id) => shownIds.has(id)), + ); layerState._cardGraceState = retention.graceState; // Bounded thumbnail LRU: live cards (grace included) never lose their @@ -371,7 +379,6 @@ export function createCards({ state: layerState, services, parts, source }) { layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID); layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false); parts.hover.clearHoverCard(); - layerState._cameraMoving = false; layerState._cardIds = new Set(); layerState._cardGraceState = new Map(); layerState._cardFrameSlots = new Map(); diff --git a/src/layers/cctv/controls.js b/src/layers/cctv/controls.js index af9f1f427..492ff1758 100644 --- a/src/layers/cctv/controls.js +++ b/src/layers/cctv/controls.js @@ -205,22 +205,21 @@ export function createControls({ state: layerState, services, parts, source }) { * @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]} */ getDetectableObjects(options = {}) { - if (!layerState._enabled || layerState._records.length === 0) return []; + // The cameras the view pass shows, not icons it has hidden. + const records = layerState._viewRecords; + if (!layerState._enabled || records.length === 0) return []; const maxCount = Number.isFinite(options.maxCount) ? Math.max(1, Math.floor(options.maxCount)) - : layerState._records.length; + : records.length; const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - const stride = Math.max( - 1, - Math.ceil(layerState._records.length / maxCount), - ); + const stride = Math.max(1, Math.ceil(records.length / maxCount)); const start = seed % stride; const objects = []; - for (let i = start; i < layerState._records.length; i += stride) { - const camera = layerState._records[i].camera; + for (let i = start; i < records.length; i += stride) { + const camera = records[i].camera; objects.push({ - position: layerState._records[i].position, + position: records[i].position, sourceId: camera.id, // Short semantic code where the id is opaque (see cameraDisplayCode). id: `CAM-${camera.code || camera.id}`, diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index d9db40fe9..362ad4789 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -581,6 +581,13 @@ export function createGeometry({ state: layerState, services, parts, source }) { function coverageNeighborCount(targetRecord) { if (!targetRecord) return 0; + // The summary asks on every notification; scan the catalog only when the + // camera, its pose or the catalog changed. + const { id, lat, lon, rangeM } = targetRecord.camera; + const key = `${id}|${lat}|${lon}|${rangeM}|${layerState._records.length}`; + const cached = layerState._neighborCountCache; + if (cached?.key === key && cached.records === layerState._records) + return cached.count; let count = 0; for (const record of layerState._records) { if (record === targetRecord) continue; @@ -594,6 +601,11 @@ export function createGeometry({ state: layerState, services, parts, source }) { ((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92; if (dKm <= overlapKm) count++; } + layerState._neighborCountCache = { + key, + records: layerState._records, + count, + }; return count; } diff --git a/src/layers/cctv/geometryQueue.js b/src/layers/cctv/geometryQueue.js index 5583e36af..b78950aaa 100644 --- a/src/layers/cctv/geometryQueue.js +++ b/src/layers/cctv/geometryQueue.js @@ -6,6 +6,8 @@ import { GEO_TRACKING_BATCH_DELAY_MS, GEO_LOAD_BATCH_SIZE, GEO_LOAD_BATCH_DELAY_MS, + VIEW_GEOMETRY_ATTEMPTS, + VIEW_GEOMETRY_RETRY_MS, } from './policy.js'; export function createGeometryQueue({ @@ -25,6 +27,8 @@ export function createGeometryQueue({ clearTimeout(layerState._geoQueueTimer); layerState._geoQueueTimer = 0; } + clearTimeout(layerState._viewGeometryRetryTimer); + layerState._viewGeometryRetryTimer = 0; layerState._geoQueue = []; layerState._geoProgressNotifier = null; if (clearProgress) { @@ -223,6 +227,7 @@ export function createGeometryQueue({ err?.message || err, ); } + noteViewAttempt(record); if ( layerState._geoLoading && layerState._geoLoadDone < layerState._geoLoadTotal @@ -236,14 +241,15 @@ export function createGeometryQueue({ layerState._geoLoading = false; if (wasInitialLoad) { layerState._geoLoadDone = layerState._geoLoadTotal; - if (layerState._enabled) { - parts.rendering.refreshCoverageStyles(); - // Geometry refinement may have replaced record.position objects — the - // one-shot drain completion re-anchors the card entries (event-driven, - // not a per-frame or timer pass). - parts.cards.refreshAmbientCards(); - } + if (layerState._enabled) parts.rendering.refreshCoverageStyles(); } + // Geometry refinement may have replaced record.position objects — every + // drain's completion re-anchors the card entries (event-driven, not a + // per-frame or timer pass). + // While the camera moves, moveEnd reselects them instead. + if (layerState._enabled && !layerState._cameraMoving) + parts.cards.refreshAmbientCards(); + scheduleViewGeometryRetry(); // Completion is never coalesced: subscribers must observe the final // loading state even if the last progress tick just happened. layerState._geoProgressNotifier?.finish(); @@ -261,16 +267,17 @@ export function createGeometryQueue({ /** * Appends records to the geometry queue (no progress tracking) and starts - * the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion - * pass (records left `!groundResolved` by an enable-time drain that ran while - * tiles were still streaming) so it shares the same stagger machinery as the - * initial load. Fires at most once per enable — never on a recurring timer. + * the batch timer if idle. Used by update()'s one-shot tiles-ready + * completion pass and by enqueueViewGeometry, so both share the initial + * load's stagger machinery. Never driven by a recurring timer. * @param {Object[]} records - Camera records needing geometry refresh. */ function enqueueGeometryRefresh(records) { + const queued = new Set(layerState._geoQueue); for (const record of records) { - if (!layerState._geoQueue.includes(record)) { + if (!queued.has(record)) { + queued.add(record); layerState._geoQueue.push(record); } } @@ -283,10 +290,10 @@ export function createGeometryQueue({ } /** - * Starts the initial staggered load: orders all records active-camera-first, - * then by distance from the current viewer position (nearest first, so - * cameras likely in view refine before off-screen ones), and exposes - * loaded/total progress through uiState()/getStats() while running. + * Starts the initial staggered load over the geometry working set: active + * camera first, then by distance from the current viewer position (nearest + * first), and exposes loaded/total progress through uiState()/getStats() + * while running. Cameras outside the view refine when they come into it. */ function startGeometryLoadQueue() { @@ -303,7 +310,7 @@ export function createGeometryQueue({ const refLon = carto ? Cesium.Math.toDegrees(carto.longitude) : (active?.camera.lon ?? 0); - const pending = layerState._records + const pending = geometryWorkingSet() .filter((record) => record !== active) .map((record) => ({ record, @@ -325,7 +332,134 @@ export function createGeometryQueue({ ); layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0); } + /** + * The records whose geometry the view needs: the shown icons, the active + * camera and the coverage cohort drawn around it. + * @returns {Object[]} + */ + + function geometryWorkingSet() { + const records = new Set(layerState._viewRecords); + const active = parts.selection.getActiveRecord(); + if (active) records.add(active); + for (const id of layerState._coverageVisibleIds) { + const record = layerState._recordById.get(id); + if (record) records.add(record); + } + return [...records]; + } + + /** The regime key a view attempt counts against: interim while tiles stream. */ + function viewAttemptKey() { + const regime = parts.ground.currentSurfaceRegime(); + const tilesReady = + regime !== 'google-3d' || parts.model.projectionTilesReady(); + return { key: tilesReady ? regime : `${regime}:interim`, tilesReady }; + } + + /** + * Counts a processed geometry attempt against the current regime key; each + * key keeps its own count, so tile streaming going on and off does not + * reset the ready-tiles budget. + */ + function noteViewAttempt(record) { + const { key } = viewAttemptKey(); + record.viewAttempts ??= {}; + record.viewAttempts[key] = (record.viewAttempts[key] || 0) + 1; + record.viewAttemptAt = performance.now(); + } + + /** + * Whether a record may be attempted again under `key`. After a first + * unresolved attempt, the second waits out VIEW_GEOMETRY_RETRY_MS so its + * elevation cell can warm, rather than going to the next motion tick. + */ + /** Whether a record still has an attempt left under the current key. */ + function withinViewBudget(record) { + const { key } = viewAttemptKey(); + return (record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS; + } + + function attemptsLeft(record, key) { + const attempts = record.viewAttempts?.[key] || 0; + if (attempts >= VIEW_GEOMETRY_ATTEMPTS) return false; + return ( + attempts === 0 || + performance.now() - record.viewAttemptAt >= VIEW_GEOMETRY_RETRY_MS + ); + } + + /** + * After a drain, gives working-set cameras it left ungrounded one more + * attempt once their elevation cells have had time to warm. The timer is + * set for the earliest such camera and re-arms for the rest when it fires. + */ + function scheduleViewGeometryRetry() { + if (!layerState._enabled || layerState._viewGeometryRetryTimer) return; + const { key } = viewAttemptKey(); + const active = parts.selection.getActiveRecord(); + let due = Infinity; + for (const record of geometryWorkingSet()) { + const attempts = record.viewAttempts?.[key] || 0; + if ( + record === active || + attempts === 0 || + attempts >= VIEW_GEOMETRY_ATTEMPTS || + !parts.ground.needsGeometry(record) + ) + continue; + due = Math.min(due, record.viewAttemptAt + VIEW_GEOMETRY_RETRY_MS); + } + if (due === Infinity) return; + layerState._viewGeometryRetryTimer = setTimeout( + () => { + layerState._viewGeometryRetryTimer = 0; + enqueueViewGeometry(); + // A drain re-arms on completion; with none running, the cameras still + // cooling down need their own timer. + if (!layerState._geoQueueTimer) scheduleViewGeometryRetry(); + }, + Math.max(0, due - performance.now()), + ); + } + + /** + * Queues working-set cameras the current surface has not grounded yet, + * after a camera move brings new ones into view. Attempts are counted when + * a record is processed, not queued, so a drain cut short by disable() is + * not lost; each record gets VIEW_GEOMETRY_ATTEMPTS per surface regime, + * spaced by VIEW_GEOMETRY_RETRY_MS, so a sample the shared floor refuses + * (too far, too high) is not retried on every move. While photoreal tiles are still streaming the attempts are + * interim, and the update() completion pass is re-armed to retry these + * records when they land. The active camera is left to selection, which + * owns its re-sampling. + */ + + function enqueueViewGeometry() { + if (!layerState._enabled) return; + const { key, tilesReady } = viewAttemptKey(); + const active = parts.selection.getActiveRecord(); + const pending = []; + let coolingDown = false; + for (const record of geometryWorkingSet()) { + if (record === active || !parts.ground.needsGeometry(record)) continue; + if (!attemptsLeft(record, key)) { + coolingDown ||= + (record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS; + continue; + } + pending.push(record); + } + // A camera back in view during its cooldown still needs its retry timer. + if (coolingDown && !layerState._geoQueueTimer) scheduleViewGeometryRetry(); + if (!pending.length) return; + if (!tilesReady) layerState._tilesReadyReenqueued = false; + enqueueGeometryRefresh(pending); + } return { + enqueueViewGeometry, + withinViewBudget, + geometryWorkingSet, stopGeometryLoadQueue, createGeometryProgressNotifier, processCctvGeometryQueueBatch, diff --git a/src/layers/cctv/ground.js b/src/layers/cctv/ground.js index 5ff41c377..fdf36db14 100644 --- a/src/layers/cctv/ground.js +++ b/src/layers/cctv/ground.js @@ -1,3 +1,4 @@ +import * as Cesium from 'cesium'; import { SUPPORT_KEYS, planeSupportPoints, @@ -78,6 +79,14 @@ export function createGround({ state: layerState, services, parts, source }) { return record?.groundResolved?.[regime] === true; } + /** + * True until the record has geometry grounded for the regime. Cameras are + * refined only once they enter the view, so many never have geometry. + */ + function needsGeometry(record, regime = currentSurfaceRegime()) { + return !record?.frustumGeometry || !isGroundResolved(record, regime); + } + /** * Ground altitude used for pure geometry recomputes: the given regime's * cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor @@ -184,8 +193,11 @@ export function createGround({ state: layerState, services, parts, source }) { * Task 5: applies a LATE-arriving ground-prior batch (init's bounded race * lost — cold proxy cache / slow upstream). Pure recomputes only, no scene * queries: + * - records without geometry yet: only the icon moves onto the prior; + * the frustum is built when the camera enters the view. * - terrain-globe regime: the prior IS the resolution → re-run the - * resolution (updateRecordGeometry latches it) for every record. + * resolution (updateRecordGeometry latches it) for every record with + * geometry. * - google-3d regime: records still awaiting a shared floor move from the * catalog fallback onto the exact prior; records already holding a shared * mesh/DEM floor keep it untouched. @@ -198,6 +210,7 @@ export function createGround({ state: layerState, services, parts, source }) { function applyLateGroundPriors(records, priors) { if (!Array.isArray(records) || !Array.isArray(priors)) return; let applied = 0; + let moved = 0; for (let i = 0; i < records.length && i < priors.length; i++) { const record = records[i]; const prior = priors[i]; @@ -212,6 +225,24 @@ export function createGround({ state: layerState, services, parts, source }) { record.camera.absoluteHeightM = prior.ellipsoid + record.camera.mountHeightM; } + // Cameras without geometry only move their icon onto the prior, so the + // view pass projects the right point; their frustums are built when + // they enter the view, as recomputing the whole catalog here would + // stall a large one. + if (!record.frustumGeometry) { + if (record.position) { + Cesium.Cartesian3.fromDegrees( + record.camera.lon, + record.camera.lat, + record.camera.absoluteHeightM, + undefined, + record.position, + ); + if (record.billboard) record.billboard.position = record.position; + moved += 1; + } + continue; + } const regime = currentSurfaceRegime(); if (regime === 'terrain-globe') { // Prior IS the resolution — re-latch onto the fresh value. @@ -224,7 +255,9 @@ export function createGround({ state: layerState, services, parts, source }) { applied += 1; } } - if (applied) parts.presentation.notifyListeners(); + if (moved && parts.rendering.refreshHorizonCulling()) + parts.geometryQueue.enqueueViewGeometry(); + if (applied || moved) parts.presentation.notifyListeners(); } /** @@ -234,7 +267,7 @@ export function createGround({ state: layerState, services, parts, source }) { * deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so * on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and * no-ops): - * 1. every record's geometry recomputes IMMEDIATELY from the new regime's + * 1. every record that has geometry recomputes IMMEDIATELY from the new regime's * resolution — cached sample if that regime has one, else the Re:Earth * prior (never blank, zero scene queries); * 2. entering google-3d re-arms the one-shot tiles-ready completion latch so @@ -250,6 +283,9 @@ export function createGround({ state: layerState, services, parts, source }) { layerState._lastAppliedRegime = regime; for (const record of layerState._records) { + // Only cameras that already have geometry are rewritten; the rest are + // grounded for the new regime when they enter the view. + if (!record.frustumGeometry) continue; if (regime === 'terrain-globe') { // Prior IS the resolution — latch it (zero scene queries). record.groundSamples['terrain-globe'] = groundPriorAltFor(record); @@ -353,6 +389,7 @@ export function createGround({ state: layerState, services, parts, source }) { resolveFootprintGround, groundPriorAltFor, isGroundResolved, + needsGeometry, groundAltFor, rearmGroundResolution, resolveCommittedGroundAnchor, diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 0deba580e..6f1cc77c7 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -91,6 +91,9 @@ export function createCctvLayer({ services, source }) { processGeometryBatch: parts.geometryQueue.processGeometryBatch, hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals, refreshCoverageStyles: parts.rendering.refreshCoverageStyles, + refreshHorizonCulling: parts.rendering.refreshHorizonCulling, + _viewRecordIdsForTest: () => + state._viewRecords.map((record) => record.camera.id), clearProbeClampOnDeactivation: parts.geometry.clearProbeClampOnDeactivation, cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation, diff --git a/src/layers/cctv/ingestion.js b/src/layers/cctv/ingestion.js index 0947ccdb6..2613de1e1 100644 --- a/src/layers/cctv/ingestion.js +++ b/src/layers/cctv/ingestion.js @@ -32,13 +32,22 @@ export function createIngestion({ // projectionTilesReady() is false while a (hidden) Google tileset // exists, so this latch effectively fires for the google-3d regime — // terrain-globe records resolve from the prior in their drain pass. - const unresolved = layerState._records.filter( - (record) => !parts.ground.isGroundResolved(record), - ); + const unresolved = parts.geometryQueue + .geometryWorkingSet() + .filter( + (record) => + !parts.ground.isGroundResolved(record) && + parts.geometryQueue.withinViewBudget(record), + ); if (unresolved.length) parts.geometryQueue.enqueueGeometryRefresh(unresolved); } await parts.health.syncHealthState(); + // New health can change which camera wins a crowded cell. + if (layerState._enabled && parts.rendering.refreshHorizonCulling()) { + parts.geometryQueue.enqueueViewGeometry(); + parts.cards.refreshAmbientCards(); + } parts.navigation.maybeAutoHop(now); parts.presentation.notifyListeners(); }, diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 6d041aa78..4e4443df8 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -7,8 +7,14 @@ import { CAMERA_ICON, IDLE_CAMERA_COLOR, CALIBRATION_RANGE_FLOOR_M, + VIEW_MOTION_REFRESH_MS, + VIEW_MOTION_MIN_MOVE_FRACTION, + VIEW_MOTION_MIN_TURN_DEG, + VIEW_MOTION_COST_FACTOR, } from './policy.js'; +const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180); + export function createLifecycle({ state: layerState, services, @@ -22,15 +28,94 @@ export function createLifecycle({ const { onFocusTargetAppear } = services.focus; const { releaseContinuousRender } = services.render; + /** + * The settled-view pass: thin the icons, queue the newly visible geometry, + * reselect cards, and tell subscribers (the camera picker) when the shown + * set changed since they were last told, including by motion passes. + */ + function settleView() { + const changed = parts.rendering.refreshHorizonCulling(); + noteViewPose(); + parts.geometryQueue.enqueueViewGeometry(); + parts.cards.refreshAmbientCards(); + if (changed || layerState._viewUnpublished) { + layerState._viewUnpublished = false; + parts.presentation.notifyListeners(); + } + } + + /** Records the camera pose the last view pass ran at. */ + function noteViewPose() { + const camera = layerState._viewer?.camera; + if (!camera?.positionWC) return; + layerState._viewPose = { + position: Cesium.Cartesian3.clone(camera.positionWC), + direction: + camera.directionWC && Cesium.Cartesian3.clone(camera.directionWC), + }; + } + + /** + * Whether the camera moved or turned enough since the last view pass to + * change what is on screen. A camera that tracks or flies without settling + * would otherwise rerun the full pass on every tick. + */ + function viewPoseChanged() { + const camera = layerState._viewer?.camera; + const pose = layerState._viewPose; + if (!pose || !camera?.positionWC) return true; + const height = Math.max(1, camera.positionCartographic?.height ?? 0); + const moved = Cesium.Cartesian3.distance(camera.positionWC, pose.position); + if (moved > height * VIEW_MOTION_MIN_MOVE_FRACTION) return true; + if (!pose.direction || !camera.directionWC) return false; + return ( + Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < MIN_TURN_COS + ); + } + + function viewMotionTick() { + if (!layerState._enabled) return; + const now = performance.now(); + const pass = layerState._viewMotionPass; + if (pass && now - pass.at < pass.ms * VIEW_MOTION_COST_FACTOR) return; + if (!viewPoseChanged()) return; + if (parts.rendering.refreshHorizonCulling()) + layerState._viewUnpublished = true; + noteViewPose(); + parts.geometryQueue.enqueueViewGeometry(); + layerState._viewMotionPass = { at: now, ms: performance.now() - now }; + } + + function startViewMotionTimer() { + if (layerState._viewMotionTimer) return; + layerState._viewMotionTimer = setInterval( + viewMotionTick, + VIEW_MOTION_REFRESH_MS, + ); + } + + function stopViewMotionTimer() { + clearInterval(layerState._viewMotionTimer); + layerState._viewMotionTimer = 0; + } + /** Resets all module-scoped runtime state to initial values. */ function clearRuntimeState() { parts.geometryQueue.stopGeometryLoadQueue(); + stopViewMotionTimer(); // Idempotent — also covers a re-init without a prior destroy(). parts.cards.teardownAmbientCards(); parts.projection.clearProjectionOverlay(); layerState._records = []; layerState._recordById = new Map(); + layerState._viewRecords = []; + layerState._cameraIndexCache = null; + layerState._coverageVisibleIds = new Set(); + layerState._neighborCountCache = null; + layerState._viewUnpublished = false; + layerState._viewPose = null; + layerState._viewMotionPass = null; layerState._healthById = new Map(); layerState._count = 0; layerState._lastUpdate = null; @@ -71,6 +156,13 @@ export function createLifecycle({ layerState._calibrationById = parts.calibration.loadCalibrationStore(); layerState._billboards = new Cesium.BillboardCollection(); + // Cesium switches a property's buffer-usage hint whenever it starts or + // stops changing from one frame to the next, and every switch rewrites + // the vertex data of every billboard. The view pass toggles `show` on a + // few icons of a large catalog at a time, so on a 33K catalog each pass + // cost two full rewrites. With the hint fixed, Cesium uploads only the + // icons that changed. + layerState._billboards.computeNewBuffersUsage = () => false; layerState._viewer.scene.primitives.add(layerState._billboards); registerSpriteCollection('cctv', layerState._billboards); @@ -243,28 +335,76 @@ export function createLifecycle({ // Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with // the billboards' always-on-top depth setting) + one initial pass so the // first paint is already culled. + // A re-init against a new viewer moves the move listeners to its camera. + if ( + layerState._viewListenerCamera && + layerState._viewListenerCamera !== layerState._viewer.camera + ) { + const previous = layerState._viewListenerCamera; + if (layerState._horizonCullListener) + previous.moveEnd?.removeEventListener( + layerState._horizonCullListener, + ); + if (layerState._moveStartListener) + previous.moveStart?.removeEventListener( + layerState._moveStartListener, + ); + layerState._horizonCullListener = null; + layerState._moveStartListener = null; + layerState._cameraMoving = false; + } + layerState._viewListenerCamera = layerState._viewer.camera; if (!layerState._horizonCullListener) { // Ambient cards piggyback the same settle event: moveEnd-driven // reselection only, never per frame (refreshAmbientCards no-ops while // the layer is disabled). layerState._horizonCullListener = () => { layerState._cameraMoving = false; - parts.rendering.refreshHorizonCulling(); - parts.cards.refreshAmbientCards(); + stopViewMotionTimer(); + settleView(); }; layerState._viewer.camera.moveEnd.addEventListener( layerState._horizonCullListener, ); } if (!layerState._moveStartListener) { - // Item B: hover picking pauses while the camera is in motion. + // Item B: hover picking pauses while the camera is in motion. A long + // move (cockpit flight, a drag) never settles, so the view pass also + // reruns on an interval until it does. layerState._moveStartListener = () => { layerState._cameraMoving = true; + // enable() starts it for a move already under way. + if (layerState._enabled) startViewMotionTimer(); }; layerState._viewer.camera.moveStart.addEventListener( layerState._moveStartListener, ); } + // A resize or rotation changes what is on screen without moving the + // camera. + const canvas = layerState._viewer.scene?.canvas; + if (layerState._viewResizeCanvas !== canvas) { + // A re-init against a new viewer observes its canvas instead. + layerState._viewResizeObserver?.disconnect(); + layerState._viewResizeObserver = null; + layerState._viewResizeCanvas = null; + } + if ( + !layerState._viewResizeObserver && + typeof ResizeObserver === 'function' && + canvas + ) { + let frame = 0; + layerState._viewResizeObserver = new ResizeObserver(() => { + if (frame || !layerState._enabled) return; + frame = requestAnimationFrame(() => { + frame = 0; + if (layerState._enabled) settleView(); + }); + }); + layerState._viewResizeObserver.observe(canvas); + layerState._viewResizeCanvas = canvas; + } parts.rendering.refreshHorizonCulling(); layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( @@ -325,6 +465,12 @@ export function createLifecycle({ ); await parts.health.syncHealthState(true); + // Health ranks crowded cells: thin again now that it has arrived (a + // no-op unless enable() ran while health was loading). + if (parts.rendering.refreshHorizonCulling()) { + parts.geometryQueue.enqueueViewGeometry(); + parts.cards.refreshAmbientCards(); + } parts.rendering.refreshCoverageStyles(); parts.presentation.notifyListeners(); restoreSpriteOrder(layerState._viewer); @@ -370,6 +516,9 @@ export function createLifecycle({ parts.projection.ensureProjectionRuntime(activeRecord); parts.frames.refreshProjectionImage(activeRecord, true); } + parts.rendering.refreshHorizonCulling(); + // Re-enabled mid-flight: moveStart has already fired. + if (layerState._cameraMoving) startViewMotionTimer(); parts.geometryQueue.startGeometryLoadQueue(); parts.rendering.refreshCoverageStyles(); parts.projection.startProjectionLoop(); @@ -402,10 +551,14 @@ export function createLifecycle({ layerState._removeFocusAppearListener = null; parts.projection.stopProjectionLoop(); parts.geometryQueue.stopGeometryLoadQueue(); + stopViewMotionTimer(); // Ambient cards tear down COMPLETELY on disable (owner design point 6): // source entries, pacer timer, in-flight handlers, and caches. parts.cards.teardownAmbientCards(); parts.rendering.hideCctvVisuals(); + // Nothing is in view while the layer is off. + layerState._viewRecords = []; + layerState._cameraIndexCache = null; parts.presentation.notifyListeners(); }, @@ -443,6 +596,13 @@ export function createLifecycle({ ); layerState._moveStartListener = null; } + // The move listeners are gone; nothing will report the end of a move. + layerState._cameraMoving = false; + layerState._viewListenerCamera = null; + stopViewMotionTimer(); + layerState._viewResizeObserver?.disconnect(); + layerState._viewResizeObserver = null; + layerState._viewResizeCanvas = null; if (layerState._gizmo) { layerState._gizmo.destroy(); layerState._gizmo = null; diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index bc369c02a..574f87527 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -60,6 +60,45 @@ export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300; export const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10; +// View thinning: camera icons show only on or near the screen, and each +// camera competes for a world-anchored grid cell sized to about VIEW_CELL_PX +// at its distance from the viewer (in power-of-two metre steps, so a pan +// keeps the same winners until a camera's distance crosses a step). Cells +// smaller than VIEW_MIN_CELL_M do not thin: +// close up every camera shows, co-located ones included. + +export const VIEW_CELL_PX = 40; + +export const VIEW_MIN_CELL_M = 60; + +// Icons this far past the screen edge (fraction of its size) still show, so +// a pan does not reveal empty ground before the next view pass. + +export const VIEW_ICON_MARGIN = 0.5; + +// While the camera moves, the view pass reruns at most this often. + +export const VIEW_MOTION_REFRESH_MS = 250; +// A motion tick is skipped unless the camera moved this fraction of its +// ellipsoid height, or turned this many degrees, since the last pass. + +export const VIEW_MOTION_MIN_MOVE_FRACTION = 0.01; + +export const VIEW_MOTION_MIN_TURN_DEG = 0.5; + +// Motion passes are spaced at least this many times the last pass's cost, so +// a large catalog spends at most about a tenth of a long move on them. + +export const VIEW_MOTION_COST_FACTOR = 10; + +// A view camera's geometry is attempted at most this many times per surface +// regime (a cold elevation cell can leave the first attempt unresolved); the +// retry runs this long after the drain that left it unresolved. + +export const VIEW_GEOMETRY_ATTEMPTS = 2; + +export const VIEW_GEOMETRY_RETRY_MS = 1500; + // Throttle for placeholder repaints — the projection RAF loop must not // re-fill a 1080p canvas on every frame while a feed image is still loading. diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 898e4605c..16c67aa4b 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -274,14 +274,53 @@ export function createPresentation({ }, activeCameraId: activeId, activeCamera: active ? getPublicCameraState(active, activeId) : null, - cameras: layerState._records.map((record) => - getPublicCameraState(record, activeId), - ), + cameraIndex: cameraIndex(), summary: buildSummaryText(), }; + // The full per-camera state is built only when a subscriber reads it: a + // catalog of tens of thousands would otherwise be rebuilt on every + // progress tick for listeners that only want the active camera. It reads + // the records live at that moment, and spreading or serializing the + // payload builds it, so callers that want a count use `count`. + let cameras = null; + Object.defineProperty(payload, 'cameras', { + enumerable: true, + get() { + cameras ??= layerState._records.map((record) => + getPublicCameraState(record, activeId), + ); + return cameras; + }, + }); return payload; } + /** + * `{ id, city, name }` for the active camera, then every camera the view + * pass shows: what the camera picker lists. A whole catalog would put tens + * of thousands of options in the DOM and slow every layout. The same array + * is returned until the view or the active camera changes, so a consumer + * can compare it by identity. + * @returns {{id: string, city: string, name: string}[]} + */ + + function cameraIndex() { + const view = layerState._viewRecords; + const active = parts.selection.getActiveRecord(); + const cached = layerState._cameraIndexCache; + if (cached?.view === view && cached.active === active) return cached.list; + const records = active + ? [active, ...view.filter((record) => record !== active)] + : view; + const list = records.map(({ camera }) => ({ + id: camera.id, + city: camera.city, + name: camera.name, + })); + layerState._cameraIndexCache = { view, active, list }; + return list; + } + /** Dispatches the current UI state to all registered subscriber callbacks. */ function notifyListeners() { diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index d10368d40..4b6f215e4 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -1,5 +1,4 @@ import * as Cesium from 'cesium'; -import { horizonOccluder } from '../../data/iconOrientation.js'; import { isHeadingEstimated } from './headingConfidence.js'; import { ACTIVE_CAMERA_COLOR, @@ -11,8 +10,21 @@ import { ACTIVE_COVERAGE_EDGE, IDLE_COVERAGE_EDGE_MUTED, ACTIVE_COVERAGE_EDGE_DEPTHFAIL, + VIEW_CELL_PX, + VIEW_ICON_MARGIN, + VIEW_MIN_CELL_M, } from './policy.js'; +/** FNV-1a over a camera id: a stable, well-spread order for crowded cells. */ +function idHash(id) { + let hash = 0x811c9dc5; + for (let i = 0; i < id.length; i++) { + hash ^= id.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + export function createRendering({ state: layerState, services, @@ -138,24 +150,186 @@ export function createRendering({ * (Google-3D regime) nothing writes far-side depth, and the billboards are now * always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge * fix), so without this pass London's cluster would shine through the planet - * from a US viewpoint. Pure math over ≤ catalog-size points; runs on - * camera.moveEnd + init only (event-driven — no steady-state work). + * from a US viewpoint. + * + * The same pass thins the view so a large catalog stays readable and cheap: + * icons well off screen hide, and each camera competes for a world-anchored + * cell sized to VIEW_CELL_PX at its distance (healthiest first, then a stable + * id order). The active camera shows whenever it is above the horizon + * and near the screen, even in a crowded cell. The shown records become + * `_viewRecords`, the working set for geometry, cards and the camera picker; + * the array keeps its identity while the shown set is unchanged. + * + * Runs on camera settle, throttled during motion, on resize, enable and + * activation changes — never per frame. + * @returns {boolean} Whether the shown set changed. */ function refreshHorizonCulling() { if ( + !layerState._enabled || !layerState._viewer || layerState._viewer.isDestroyed() || !layerState._records.length ) - return; - const occluder = horizonOccluder(layerState._viewer.camera); + return false; + const { camera, scene } = layerState._viewer; + const width = scene.canvas.clientWidth || scene.canvas.width || 0; + const height = scene.canvas.clientHeight || scene.canvas.height || 0; + const minX = -width * VIEW_ICON_MARGIN; + const maxX = width * (1 + VIEW_ICON_MARGIN); + const minY = -height * VIEW_ICON_MARGIN; + const maxY = height * (1 + VIEW_ICON_MARGIN); + // Ground metres one pixel covers per metre of distance from the viewer. + const fovy = Number.isFinite(camera.frustum?.fovy) + ? camera.frustum.fovy + : Math.PI / 3; + const metresPerPixelPerMetre = + (2 * Math.tan(fovy / 2)) / Math.max(1, height); + const viewer = camera.positionWC; + // The horizon test of Cesium's EllipsoidalOccluder, inlined: this loop + // visits every camera in the catalog. + const inverseRadii = Cesium.Ellipsoid.WGS84.oneOverRadii; + const cx = viewer.x * inverseRadii.x; + const cy = viewer.y * inverseRadii.y; + const cz = viewer.z * inverseRadii.z; + const limbSquared = cx * cx + cy * cy + cz * cz - 1; + const activeId = layerState._activeCameraId; + const project = screenProjector(scene, camera, width, height); + const pass = (layerState._viewPassId = (layerState._viewPassId || 0) + 1); + const shown = []; + const cells = new Map(); + for (const record of layerState._records) { + const position = record.billboard?.position; + if (!position) continue; + const vx = position.x * inverseRadii.x - cx; + const vy = position.y * inverseRadii.y - cy; + const vz = position.z * inverseRadii.z - cz; + const vtDotVc = -(vx * cx + vy * cy + vz * cz); + if ( + limbSquared < 0 + ? vtDotVc > 0 + : vtDotVc > limbSquared && + (vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared + ) + continue; + const screen = project(position); + if ( + !screen || + !(screen.x >= minX && screen.x <= maxX) || + !(screen.y >= minY && screen.y <= maxY) + ) + continue; + const dx = position.x - viewer.x; + const dy = position.y - viewer.y; + const dz = position.z - viewer.z; + const cellM = + VIEW_CELL_PX * + metresPerPixelPerMetre * + Math.sqrt(dx * dx + dy * dy + dz * dz); + if (record.camera.id === activeId || !(cellM >= VIEW_MIN_CELL_M)) { + record.viewPass = pass; + shown.push(record); + continue; + } + const key = worldCellKey(record, cellM); + const rank = viewRank(record); + const holder = cells.get(key); + if (!holder || rank < holder.rank) cells.set(key, { record, rank }); + } + for (const { record } of cells.values()) { + record.viewPass = pass; + shown.push(record); + } for (const record of layerState._records) { const bb = record.billboard; if (!bb) continue; - const visible = occluder.isPointVisible(bb.position); + const visible = record.viewPass === pass; if (bb.show !== visible) bb.show = visible; } + const previous = layerState._viewRecords; + const changed = + previous.length !== shown.length || + previous.some((record) => record.viewPass !== pass); + if (changed) layerState._viewRecords = shown; + return changed; + } + + /** + * Canvas coordinates for world positions. In 3D the pass multiplies by one + * view-projection matrix per call instead of Cesium's per-point + * cartesianToCanvasCoordinates, which dominated the pass on a large + * catalog; other scene modes keep Cesium's projection. + * @returns {(position: Cesium.Cartesian3) => ({x: number, y: number}|null)} + */ + function screenProjector(scene, camera, width, height) { + const out = layerState._scratchViewScreen; + const projection = camera.frustum?.projectionMatrix; + if ( + scene.mode !== Cesium.SceneMode.SCENE3D || + !projection || + !camera.viewMatrix + ) + return (position) => scene.cartesianToCanvasCoordinates(position, out); + const m = Cesium.Matrix4.multiply( + projection, + camera.viewMatrix, + layerState._scratchViewProjection, + ); + return (p) => { + const w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15]; + const z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14]; + // Behind the eye or before the near plane: Cesium does not draw it. + if (!(w > 0) || z < 0) return null; + out.x = + ((m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12]) / w + 1) * 0.5 * width; + out.y = + (1 - (m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13]) / w) * 0.5 * height; + return out; + }; + } + + /** + * A grid cell fixed to the ground: `cellM` rounded up to a power of two + * metres, so the cell (and its winner) only changes when the camera's + * distance crosses a doubling: a level pan keeps it, a tilted pan or a zoom + * can move cameras near the edge of a step to the next one. + */ + function worldCellKey(record, cellM) { + const level = Math.ceil(Math.log2(cellM)); + const { lat, lon } = record.camera; + // A camera's key changes only with the level (or an edited position), so + // it is kept per level. + if ( + record.viewCellLevel === level && + record.viewCellLat === lat && + record.viewCellLon === lon + ) + return record.viewCellKey; + const latStep = 2 ** level / 111_320; + const row = Math.floor(lat / latStep); + const rowLat = Cesium.Math.toRadians((row + 0.5) * latStep); + const lonStep = latStep / Math.max(0.05, Math.cos(rowLat)); + record.viewCellLevel = level; + record.viewCellLat = lat; + record.viewCellLon = lon; + record.viewCellKey = `${level}:${row}:${Math.floor(lon / lonStep)}`; + return record.viewCellKey; + } + + /** Lower ranks win a crowded cell: healthy feeds first, then a stable id order. */ + function viewRank(record) { + const status = layerState._healthById.get(record.camera.id)?.status; + const tier = + status === 'ok' + ? 0 + : status === 'degraded' + ? 1 + : status === 'offline' + ? 3 + : 2; + record.viewHash ??= idHash(record.camera.id); + return tier * 2 ** 32 + record.viewHash; } /** @@ -204,6 +378,8 @@ export function createRendering({ const activeId = activeRecord?.camera.id || null; const coverageVisible = parts.geometry.buildCoverageVisibleSet(activeRecord); + // Drawn coverage needs geometry too (geometryQueue's working set). + layerState._coverageVisibleIds = coverageVisible; const coverageOn = layerState._coverageMode !== 'off'; const viewshedOn = layerState._coverageMode === 'viewshed'; if (coverageOn) { @@ -215,8 +391,13 @@ export function createRendering({ for (const record of layerState._records) { const isActive = record.camera.id === activeId; if (record.billboard) { - record.billboard.color = cameraColorFor(record); - record.billboard.scale = isActive ? 1.25 : 1.0; + // Unchanged writes still dirty the collection, which then rewrites + // every icon's vertices: across a large catalog, write only changes. + const color = cameraColorFor(record); + if (!Cesium.Color.equals(record.billboard.color, color)) + record.billboard.color = color; + const scale = isActive ? 1.25 : 1.0; + if (record.billboard.scale !== scale) record.billboard.scale = scale; // disableDepthTestDistance stays POSITIVE_INFINITY for every billboard // (set at creation) — see the field-test far-zoom submerge fix there. } diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js index 317d80c7e..c86425fd0 100644 --- a/src/layers/cctv/selection.js +++ b/src/layers/cctv/selection.js @@ -141,6 +141,11 @@ export function createSelection({ void parts.ground.resolveFootprintGround(record); record.activationDone = true; parts.rendering.refreshCoverageStyles(); + // The active camera's icon always shows; the previously active one + // competes for its cell again. Its coverage neighbours join the working + // set and are queued for grounding. + parts.rendering.refreshHorizonCulling(); + parts.geometryQueue.enqueueViewGeometry(); // The newly active camera leaves the ambient ring (its monitor plane takes // over); the freed slot re-fills on this same pass. parts.cards.refreshAmbientCards(); @@ -171,7 +176,9 @@ export function createSelection({ parts.ground.groundAltFor(previous), ); }); + parts.rendering.refreshHorizonCulling(); parts.rendering.refreshCoverageStyles(); + parts.geometryQueue.enqueueViewGeometry(); parts.cards.refreshAmbientCards(); layerState._gizmo?.refresh(); parts.presentation.notifyListeners(); diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js index b78d18b73..78c2b8fa9 100644 --- a/src/layers/cctv/state.js +++ b/src/layers/cctv/state.js @@ -76,6 +76,63 @@ export function createState({ services }) { layerState._scratchFocusScreen = new Cesium.Cartesian2(); + layerState._scratchViewScreen = new Cesium.Cartesian2(); + + layerState._scratchViewProjection = new Cesium.Matrix4(); + + // Counts view passes; a record shown by the latest pass carries its number. + + layerState._viewPassId = 0; + + // Records whose icons the last view pass showed (see refreshHorizonCulling): + // geometry refinement, the tiles-ready completion pass and ambient cards + // work over these, not the whole catalog. + + layerState._viewRecords = []; + + // Coverage ids refreshCoverageStyles last drew: part of the geometry + // working set even when their icons are thinned out. + + layerState._coverageVisibleIds = new Set(); + + // View pass triggers besides camera settle: an interval while the camera + // moves, and a canvas resize observer. + + layerState._viewMotionTimer = 0; + + layerState._viewResizeObserver = null; + + layerState._viewResizeCanvas = null; + + // The camera the moveStart/moveEnd listeners are attached to. + + layerState._viewListenerCamera = null; + + // A motion pass changed the shown set and the settle pass has not yet told + // subscribers; the camera pose the last view pass ran at. + + layerState._viewUnpublished = false; + + layerState._viewPose = null; + + // { at, ms } — when the last motion pass ran and what it cost. + + layerState._viewMotionPass = null; + + // Retries the view geometry a drain left unresolved, once cells warm. + + layerState._viewGeometryRetryTimer = 0; + + // { key, count } — the active camera's coverage-overlap count, which the + // summary reads on every notification. + + layerState._neighborCountCache = null; + + // { view, active, list } — the camera picker's index uiState publishes, + // rebuilt only when the view pass or the active camera changes. + + layerState._cameraIndexCache = null; + // Staggered geometry-load queue state (see startGeometryLoadQueue). layerState._geoQueue = []; diff --git a/src/layers/cctv/testing.js b/src/layers/cctv/testing.js index 5c1e67956..c4aada9bb 100644 --- a/src/layers/cctv/testing.js +++ b/src/layers/cctv/testing.js @@ -79,6 +79,7 @@ export function createTesting({ state: layerState, services, parts, source }) { * @param {boolean} [options.enabled=true] Layer enabled state. * @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode. * @param {boolean} [options.showProjection=false] Projection visibility. + * @param {Map} [options.healthById] Health rows by camera id. * @returns {void} */ @@ -89,6 +90,7 @@ export function createTesting({ state: layerState, services, parts, source }) { enabled = true, coverageMode = 'on', showProjection = false, + healthById = new Map(), } = {}) { layerState._viewer = viewer; layerState._records = Array.isArray(records) ? records : []; @@ -108,6 +110,11 @@ export function createTesting({ state: layerState, services, parts, source }) { 'on', ); layerState._showProjection = !!showProjection; + layerState._healthById = healthById; + layerState._viewRecords = []; + layerState._cameraIndexCache = null; + layerState._coverageVisibleIds = new Set(); + layerState._neighborCountCache = null; } return { _pushAmbientCardEntriesForTest, diff --git a/src/ui/cctvBindings.js b/src/ui/cctvBindings.js index d79a9e99a..717770989 100644 --- a/src/ui/cctvBindings.js +++ b/src/ui/cctvBindings.js @@ -59,6 +59,12 @@ export function _initCctvPanel() { ); }); + // The picker defers rebuilds while focused; catch up once it lets go. + this.listen(this._cctvSelect, 'blur', () => { + this._cctvSelectSource = null; + this._renderCctvState(this._cctvState); + }); + this.listen(this._cctvSelect, 'change', async () => { const generation = ++this._actionGeneration; const activeId = this._cctvState?.activeCameraId; @@ -72,9 +78,9 @@ export function _initCctvPanel() { (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. + // Picking a camera from the dropdown flies to it. The dropdown lists the + // cameras in view (and the active camera, which may be far away), so a + // bare selection could leave the view somewhere else. this.actions.runExplicitFocus( () => (this.cctv.selectCamera(cameraId) ? cameraId : null), (selectedId) => this.cctv.focusCamera(selectedId, 2.2), diff --git a/src/ui/cctvPicker.test.mjs b/src/ui/cctvPicker.test.mjs new file mode 100644 index 000000000..c96fdeacb --- /dev/null +++ b/src/ui/cctvPicker.test.mjs @@ -0,0 +1,160 @@ +// src/ui/cctvPicker.test.mjs — the camera picker lists the cameras in view and +// says how many more the view leaves out. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CctvControls } from './cctvControls.js'; + +/** The slice of a