mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Show only the CCTV cameras in view, thinned to one per ground cell when zoomed out
The CCTV layer kept every loaded camera live: each view change touched every icon, every notification rebuilt every camera's state, and the picker listed the whole catalog. With the default 4,000 cameras, a continental drag ran at 152-179 fps with 18-24 frames over 30 ms (worst 112 ms). It now runs at 235 fps with none. The layer now: - shows icons only on or near the screen, and when zoomed out keeps one camera per world-anchored cell (healthy feeds first), so panning keeps the same cameras; - grounds and draws coverage only for the cameras in view, the active camera and its coverage neighbours, retrying a camera at most twice per view regime; - reruns the view pass while the camera moves, paced by its own cost, and projects with one matrix per pass instead of a Cesium call per camera; - keeps the billboard buffer-usage hint fixed, so showing or hiding a few icons no longer rewrites the vertex data of every billboard; - lists the cameras in view (active camera first) in the picker, closing with the count of cameras outside the view, and builds the full camera list in the UI state only when it is read.
This commit is contained in:
1 parent
f13c60c477
commit
93942d0dbe
32 files changed
+1301
-71
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.": "利用できる概要はありません。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.": "Сводка недоступна.",
|
||||
|
||||
@@ -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.": "Зведення недоступне.",
|
||||
|
||||
@@ -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.": "暂无摘要。",
|
||||
|
||||
@@ -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.": "暫無摘要。",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+8
-9
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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<string, Object>} [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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 <select> and its document the picker touches. */
|
||||
function pickerDocument() {
|
||||
const select = new EventTarget();
|
||||
const option = () => ({
|
||||
value: '',
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
dataset: {},
|
||||
remove() {
|
||||
select.options = select.options.filter((entry) => entry !== this);
|
||||
},
|
||||
});
|
||||
Object.assign(select, {
|
||||
options: [],
|
||||
disabled: false,
|
||||
selectedIndex: -1,
|
||||
get value() {
|
||||
return this.options[this.selectedIndex]?.value ?? '';
|
||||
},
|
||||
set value(id) {
|
||||
this.selectedIndex = this.options.findIndex((o) => o.value === id);
|
||||
},
|
||||
replaceChildren(fragment) {
|
||||
this.options = [...fragment.children];
|
||||
this.selectedIndex = this.options.length ? 0 : -1;
|
||||
},
|
||||
prepend(child) {
|
||||
this.options.unshift(child);
|
||||
},
|
||||
});
|
||||
const document = {
|
||||
activeElement: null,
|
||||
createElement: option,
|
||||
createDocumentFragment: () => ({
|
||||
children: [],
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { document, select };
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const { document, select } = pickerDocument();
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = document;
|
||||
const controls = new CctvControls({
|
||||
elements: { _cctvSelect: select },
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
globalThis.document = previous;
|
||||
});
|
||||
return { controls, select };
|
||||
}
|
||||
|
||||
const camera = (id) => ({ id, city: 'Austin', name: `Cam ${id}` });
|
||||
|
||||
test('the picker closes with the count of cameras outside the view', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 40,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['a', 'b', ''],
|
||||
);
|
||||
const note = select.options[2];
|
||||
assert.equal(note.disabled, true);
|
||||
assert.equal(
|
||||
note.textContent,
|
||||
'38 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.selectedIndex, -1, 'nothing active, nothing picked');
|
||||
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['a', 'b'],
|
||||
'a view holding every camera has no note',
|
||||
);
|
||||
});
|
||||
|
||||
test('with no camera in view, the closed picker shows the note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 1);
|
||||
assert.equal(
|
||||
select.options[select.selectedIndex].textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
});
|
||||
|
||||
test('a disabled layer has no note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({ enabled: false, count: 40, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 0);
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
});
|
||||
|
||||
test('a camera activated under a focused picker counts against the note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
const a = camera('a');
|
||||
const b = camera('b');
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [a],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: a,
|
||||
});
|
||||
assert.equal(
|
||||
select.options.at(-1).textContent,
|
||||
'2 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
globalThis.document.activeElement = select;
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [b, a],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['b', 'a', ''],
|
||||
);
|
||||
assert.equal(
|
||||
select.options.at(-1).textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
|
||||
test('a focused picker does not pick a stale camera when the view empties', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [camera('a')],
|
||||
});
|
||||
globalThis.document.activeElement = select;
|
||||
controls._renderCctvState({ enabled: true, count: 3, cameraIndex: [] });
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
});
|
||||
+80
-14
@@ -16,6 +16,15 @@ export function _calBadgeLabel(badge) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The picker's closing line for the cameras the view leaves out. */
|
||||
function outsideNoteText(count) {
|
||||
return tn(
|
||||
count,
|
||||
'{count} more camera outside this view · pan or zoom out',
|
||||
'{count} more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
}
|
||||
|
||||
export function _renderCctvState(state) {
|
||||
if (this.destroyed) return;
|
||||
if (
|
||||
@@ -26,7 +35,13 @@ export function _renderCctvState(state) {
|
||||
this._calibrationEdit?.(false);
|
||||
}
|
||||
this._cctvState = state || null;
|
||||
const cameras = state?.cameras || [];
|
||||
// The light index, not `state.cameras`: reading that builds every camera's
|
||||
// full state, which a large catalog cannot afford on each notification.
|
||||
const cameras = state?.cameraIndex || state?.cameras || [];
|
||||
// The picker lists what is in view; the catalog is every camera.
|
||||
const catalogCount = Number.isFinite(state?.count)
|
||||
? state.count
|
||||
: cameras.length;
|
||||
const enabled = !!state?.enabled && !!this.actions.isEnabled();
|
||||
const activeId = state?.activeCameraId || '';
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
@@ -59,28 +74,79 @@ export function _renderCctvState(state) {
|
||||
}
|
||||
|
||||
if (this._cctvSelect) {
|
||||
// The index keeps its identity until the view or the active camera
|
||||
// changes, so an unchanged list skips the per-option comparison. A
|
||||
// focused picker is not rebuilt under the user; blur re-renders it.
|
||||
const picking = document.activeElement === this._cctvSelect;
|
||||
// Cameras the view leaves out get one closing line, so a short list
|
||||
// does not read as the whole catalog. A disabled layer has no view.
|
||||
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
|
||||
const shouldRebuild =
|
||||
this._cctvSelect.options.length !== cameras.length ||
|
||||
cameras.some(
|
||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
||||
);
|
||||
!picking &&
|
||||
(this._cctvSelectSource !== cameras ||
|
||||
this._cctvSelectOutside !== outside) &&
|
||||
(this._cctvSelectOutside !== outside ||
|
||||
this._cctvSelect.options.length !==
|
||||
cameras.length + (outside ? 1 : 0) ||
|
||||
cameras.some(
|
||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
||||
));
|
||||
if (!picking) {
|
||||
this._cctvSelectSource = cameras;
|
||||
this._cctvSelectOutside = outside;
|
||||
}
|
||||
if (shouldRebuild) {
|
||||
this._cctvSelect.innerHTML = '';
|
||||
const options = document.createDocumentFragment();
|
||||
for (const camera of cameras) {
|
||||
const option = document.createElement('option');
|
||||
option.value = camera.id;
|
||||
option.textContent = `${camera.city} · ${camera.name}`;
|
||||
this._cctvSelect.appendChild(option);
|
||||
options.appendChild(option);
|
||||
}
|
||||
if (outside) {
|
||||
const note = document.createElement('option');
|
||||
note.value = '';
|
||||
note.disabled = true;
|
||||
note.dataset.cctvOutside = '';
|
||||
note.textContent = outsideNoteText(outside);
|
||||
options.appendChild(note);
|
||||
}
|
||||
this._cctvSelect.replaceChildren(options);
|
||||
}
|
||||
this._cctvSelect.disabled = !enabled || cameras.length === 0;
|
||||
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
|
||||
// voice); add it so the picker does not go blank.
|
||||
if (
|
||||
picking &&
|
||||
activeId &&
|
||||
Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)
|
||||
activeCamera &&
|
||||
![...this._cctvSelect.options].some((option) => option.value === activeId)
|
||||
) {
|
||||
const option = document.createElement('option');
|
||||
option.value = activeId;
|
||||
option.textContent = `${activeCamera.city} · ${activeCamera.name}`;
|
||||
this._cctvSelect.prepend(option);
|
||||
// The added camera is one fewer outside the view.
|
||||
const note = [...this._cctvSelect.options].find(
|
||||
(entry) => entry.dataset?.cctvOutside !== undefined,
|
||||
);
|
||||
if (note) {
|
||||
const left = Math.max(
|
||||
0,
|
||||
catalogCount - (this._cctvSelect.options.length - 1),
|
||||
);
|
||||
if (left) note.textContent = outsideNoteText(left);
|
||||
else note.remove();
|
||||
}
|
||||
}
|
||||
// The active camera is always one of the listed records.
|
||||
if (activeId && activeCamera) {
|
||||
this._cctvSelect.value = activeId;
|
||||
} else if (!activeId) {
|
||||
this._cctvSelect.selectedIndex = -1;
|
||||
// With nothing in view, the closed picker says where the cameras are.
|
||||
// A focused picker keeps its old options, so it only clears.
|
||||
this._cctvSelect.selectedIndex =
|
||||
!picking && cameras.length === 0 && outside ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +156,10 @@ export function _renderCctvState(state) {
|
||||
this._cctvNextBtn,
|
||||
]) {
|
||||
if (!btn) continue;
|
||||
btn.disabled = !enabled || cameras.length === 0;
|
||||
btn.disabled = !enabled || catalogCount === 0;
|
||||
}
|
||||
if (this._cctvFocusBtn) {
|
||||
this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId;
|
||||
this._cctvFocusBtn.disabled = !enabled || catalogCount === 0 || !activeId;
|
||||
}
|
||||
|
||||
if (this._cctvCoverageBtn) {
|
||||
@@ -180,15 +246,15 @@ export function _renderCctvState(state) {
|
||||
status: `${credit}${statusMsg}`,
|
||||
},
|
||||
);
|
||||
} else if (cameras.length > 0) {
|
||||
} else if (catalogCount > 0) {
|
||||
this._cctvMeta.textContent = enabled
|
||||
? tn(
|
||||
cameras.length,
|
||||
catalogCount,
|
||||
'{count} camera loaded · click a camera to activate',
|
||||
'{count} cameras loaded · click a camera to activate',
|
||||
)
|
||||
: tn(
|
||||
cameras.length,
|
||||
catalogCount,
|
||||
'{count} camera loaded · enable CCTV to activate',
|
||||
'{count} cameras loaded · enable CCTV to activate',
|
||||
);
|
||||
|
||||
@@ -1659,9 +1659,8 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) {
|
||||
return {
|
||||
activeCameraId: ui.activeCameraId || null,
|
||||
activeCamera: ui.activeCamera?.name || ui.activeCamera?.id || null,
|
||||
cameraCount: Array.isArray(ui.cameras)
|
||||
? ui.cameras.length
|
||||
: ui.count || 0,
|
||||
// `count`, not `cameras.length`: reading `cameras` builds every camera.
|
||||
cameraCount: Number.isFinite(ui.count) ? ui.count : 0,
|
||||
showCoverage: !!ui.showCoverage,
|
||||
coverageMode: ui.coverageMode || (ui.showCoverage ? 'on' : 'off'),
|
||||
showProjection: !!ui.showProjection,
|
||||
|
||||
Reference in new issue
Block a user