mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #1485 from bilawalsidhu/sameh/cctv-view-lod
perf(cctv): draw only the cameras in view, thinned when zoomed out
This commit is contained in:
33 files changed
+1302
-68
No files matched your search
@@ -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).
|
||||||
@@ -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
|
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
|
||||||
request skips the key-bearing Street View fallback and gets the placeholder.
|
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 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
|
Traffic and bikeshare expose factories through `./layers/traffic` and
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
|
|||||||
export const processGeometryBatch = layer.processGeometryBatch;
|
export const processGeometryBatch = layer.processGeometryBatch;
|
||||||
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
|
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
|
||||||
export const refreshCoverageStyles = layer.refreshCoverageStyles;
|
export const refreshCoverageStyles = layer.refreshCoverageStyles;
|
||||||
|
export const refreshHorizonCulling = layer.refreshHorizonCulling;
|
||||||
|
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
|
||||||
export const clearProbeClampOnDeactivation =
|
export const clearProbeClampOnDeactivation =
|
||||||
layer.clearProbeClampOnDeactivation;
|
layer.clearProbeClampOnDeactivation;
|
||||||
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
|
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.ok(renderer, '_renderCctvState is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
renderer[0],
|
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(
|
assert.match(
|
||||||
renderer[0],
|
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 {
|
} finally {
|
||||||
cctvLayer.setParams({ autoHop: false });
|
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",
|
"one": "{count} Kamera geladen · CCTV aktivieren",
|
||||||
"other": "{count} Kameras 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 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.",
|
"Enable CCTV to start camera-linked intelligence summaries.": "CCTV aktivieren, um kameragestützte Lagezusammenfassungen zu starten.",
|
||||||
"No summary available.": "Keine Zusammenfassung verfügbar.",
|
"No summary available.": "Keine Zusammenfassung verfügbar.",
|
||||||
|
|||||||
@@ -369,6 +369,11 @@
|
|||||||
"many": "{count} de cámaras cargadas · activa CCTV para activarlas",
|
"many": "{count} de cámaras cargadas · activa CCTV para activarlas",
|
||||||
"other": "{count} 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.",
|
"No summary available.": "No hay resumen disponible.",
|
||||||
"Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal",
|
"Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal",
|
||||||
"Detection overlay: {mode}": "Superposición de detección: {mode}",
|
"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",
|
"many": "{count} de caméras chargées · activer CCTV pour les utiliser",
|
||||||
"other": "{count} 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 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.",
|
"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.",
|
"No summary available.": "Aucun résumé disponible.",
|
||||||
|
|||||||
@@ -1625,6 +1625,11 @@
|
|||||||
"many": "{count} di telecamere caricate · attiva CCTV per attivarle",
|
"many": "{count} di telecamere caricate · attiva CCTV per attivarle",
|
||||||
"other": "{count} 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 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.",
|
"Enable CCTV to start camera-linked intelligence summaries.": "Attiva CCTV per avviare i riepiloghi intelligence collegati alle telecamere.",
|
||||||
"No summary available.": "Nessun riepilogo disponibile.",
|
"No summary available.": "Nessun riepilogo disponibile.",
|
||||||
|
|||||||
@@ -1579,6 +1579,9 @@
|
|||||||
"{count} cameras loaded · enable CCTV to activate": {
|
"{count} cameras loaded · enable CCTV to activate": {
|
||||||
"other": "カメラ {count}台を読込済み · CCTVを有効にして使用"
|
"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 load camera intersections": "CCTVを有効にすると、カメラのある交差点を読み込みます",
|
||||||
"Enable CCTV to start camera-linked intelligence summaries.": "CCTVを有効にすると、カメラ連動のインテリジェンス概要を開始します。",
|
"Enable CCTV to start camera-linked intelligence summaries.": "CCTVを有効にすると、カメラ連動のインテリジェンス概要を開始します。",
|
||||||
"No summary available.": "利用できる概要はありません。",
|
"No summary available.": "利用できる概要はありません。",
|
||||||
|
|||||||
@@ -1625,6 +1625,11 @@
|
|||||||
"many": "{count} de câmeras carregadas · ative o CCTV para usar",
|
"many": "{count} de câmeras carregadas · ative o CCTV para usar",
|
||||||
"other": "{count} 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 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.",
|
"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.",
|
"No summary available.": "Nenhum resumo disponível.",
|
||||||
|
|||||||
@@ -342,6 +342,12 @@
|
|||||||
"many": "Загружено {count} камер · включите CCTV, чтобы активировать",
|
"many": "Загружено {count} камер · включите CCTV, чтобы активировать",
|
||||||
"other": "Загружено {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 load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках",
|
||||||
"Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.",
|
"Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.",
|
||||||
"No summary available.": "Сводка недоступна.",
|
"No summary available.": "Сводка недоступна.",
|
||||||
|
|||||||
@@ -1647,6 +1647,12 @@
|
|||||||
"many": "{count} камер завантажено · увімкніть CCTV, щоб активувати",
|
"many": "{count} камер завантажено · увімкніть CCTV, щоб активувати",
|
||||||
"other": "{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 load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях",
|
||||||
"Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.",
|
"Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.",
|
||||||
"No summary available.": "Зведення недоступне.",
|
"No summary available.": "Зведення недоступне.",
|
||||||
|
|||||||
@@ -1553,6 +1553,9 @@
|
|||||||
"{count} cameras loaded · enable CCTV to activate": {
|
"{count} cameras loaded · enable CCTV to activate": {
|
||||||
"other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活"
|
"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 load camera intersections": "启用 CCTV 以加载路口摄像头",
|
||||||
"Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。",
|
"Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。",
|
||||||
"No summary available.": "暂无摘要。",
|
"No summary available.": "暂无摘要。",
|
||||||
|
|||||||
@@ -1543,6 +1543,9 @@
|
|||||||
"{count} cameras loaded · enable CCTV to activate": {
|
"{count} cameras loaded · enable CCTV to activate": {
|
||||||
"other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活"
|
"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 load camera intersections": "啟用 CCTV 以加載路口攝像頭",
|
||||||
"Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。",
|
"Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。",
|
||||||
"No summary available.": "暫無摘要。",
|
"No summary available.": "暫無摘要。",
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ export function createCards({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
const screenById = new Map();
|
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;
|
const id = record.camera.id;
|
||||||
if (id === activeId || !record.position) continue;
|
if (id === activeId || !record.position) continue;
|
||||||
let inView = false;
|
let inView = false;
|
||||||
@@ -187,7 +188,14 @@ export function createCards({ state: layerState, services, parts, source }) {
|
|||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
cardLimit: budgets.cardLimit,
|
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;
|
layerState._cardGraceState = retention.graceState;
|
||||||
|
|
||||||
// Bounded thumbnail LRU: live cards (grace included) never lose their
|
// 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.clearSource(CCTV_OVERLAY_SOURCE_ID);
|
||||||
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false);
|
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false);
|
||||||
parts.hover.clearHoverCard();
|
parts.hover.clearHoverCard();
|
||||||
layerState._cameraMoving = false;
|
|
||||||
layerState._cardIds = new Set();
|
layerState._cardIds = new Set();
|
||||||
layerState._cardGraceState = new Map();
|
layerState._cardGraceState = new Map();
|
||||||
layerState._cardFrameSlots = 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 }[]}
|
* @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]}
|
||||||
*/
|
*/
|
||||||
getDetectableObjects(options = {}) {
|
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)
|
const maxCount = Number.isFinite(options.maxCount)
|
||||||
? Math.max(1, Math.floor(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 seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||||
const stride = Math.max(
|
const stride = Math.max(1, Math.ceil(records.length / maxCount));
|
||||||
1,
|
|
||||||
Math.ceil(layerState._records.length / maxCount),
|
|
||||||
);
|
|
||||||
const start = seed % stride;
|
const start = seed % stride;
|
||||||
|
|
||||||
const objects = [];
|
const objects = [];
|
||||||
for (let i = start; i < layerState._records.length; i += stride) {
|
for (let i = start; i < records.length; i += stride) {
|
||||||
const camera = layerState._records[i].camera;
|
const camera = records[i].camera;
|
||||||
objects.push({
|
objects.push({
|
||||||
position: layerState._records[i].position,
|
position: records[i].position,
|
||||||
sourceId: camera.id,
|
sourceId: camera.id,
|
||||||
// Short semantic code where the id is opaque (see cameraDisplayCode).
|
// Short semantic code where the id is opaque (see cameraDisplayCode).
|
||||||
id: `CAM-${camera.code || camera.id}`,
|
id: `CAM-${camera.code || camera.id}`,
|
||||||
|
|||||||
@@ -581,6 +581,13 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
function coverageNeighborCount(targetRecord) {
|
function coverageNeighborCount(targetRecord) {
|
||||||
if (!targetRecord) return 0;
|
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;
|
let count = 0;
|
||||||
for (const record of layerState._records) {
|
for (const record of layerState._records) {
|
||||||
if (record === targetRecord) continue;
|
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;
|
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
|
||||||
if (dKm <= overlapKm) count++;
|
if (dKm <= overlapKm) count++;
|
||||||
}
|
}
|
||||||
|
layerState._neighborCountCache = {
|
||||||
|
key,
|
||||||
|
records: layerState._records,
|
||||||
|
count,
|
||||||
|
};
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
GEO_TRACKING_BATCH_DELAY_MS,
|
GEO_TRACKING_BATCH_DELAY_MS,
|
||||||
GEO_LOAD_BATCH_SIZE,
|
GEO_LOAD_BATCH_SIZE,
|
||||||
GEO_LOAD_BATCH_DELAY_MS,
|
GEO_LOAD_BATCH_DELAY_MS,
|
||||||
|
VIEW_GEOMETRY_ATTEMPTS,
|
||||||
|
VIEW_GEOMETRY_RETRY_MS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
export function createGeometryQueue({
|
export function createGeometryQueue({
|
||||||
@@ -25,6 +27,8 @@ export function createGeometryQueue({
|
|||||||
clearTimeout(layerState._geoQueueTimer);
|
clearTimeout(layerState._geoQueueTimer);
|
||||||
layerState._geoQueueTimer = 0;
|
layerState._geoQueueTimer = 0;
|
||||||
}
|
}
|
||||||
|
clearTimeout(layerState._viewGeometryRetryTimer);
|
||||||
|
layerState._viewGeometryRetryTimer = 0;
|
||||||
layerState._geoQueue = [];
|
layerState._geoQueue = [];
|
||||||
layerState._geoProgressNotifier = null;
|
layerState._geoProgressNotifier = null;
|
||||||
if (clearProgress) {
|
if (clearProgress) {
|
||||||
@@ -223,6 +227,7 @@ export function createGeometryQueue({
|
|||||||
err?.message || err,
|
err?.message || err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
noteViewAttempt(record);
|
||||||
if (
|
if (
|
||||||
layerState._geoLoading &&
|
layerState._geoLoading &&
|
||||||
layerState._geoLoadDone < layerState._geoLoadTotal
|
layerState._geoLoadDone < layerState._geoLoadTotal
|
||||||
@@ -236,14 +241,15 @@ export function createGeometryQueue({
|
|||||||
layerState._geoLoading = false;
|
layerState._geoLoading = false;
|
||||||
if (wasInitialLoad) {
|
if (wasInitialLoad) {
|
||||||
layerState._geoLoadDone = layerState._geoLoadTotal;
|
layerState._geoLoadDone = layerState._geoLoadTotal;
|
||||||
if (layerState._enabled) {
|
if (layerState._enabled) parts.rendering.refreshCoverageStyles();
|
||||||
parts.rendering.refreshCoverageStyles();
|
}
|
||||||
// Geometry refinement may have replaced record.position objects — the
|
// Geometry refinement may have replaced record.position objects — every
|
||||||
// one-shot drain completion re-anchors the card entries (event-driven,
|
// drain's completion re-anchors the card entries (event-driven, not a
|
||||||
// not a per-frame or timer pass).
|
// per-frame or timer pass).
|
||||||
|
// While the camera moves, moveEnd reselects them instead.
|
||||||
|
if (layerState._enabled && !layerState._cameraMoving)
|
||||||
parts.cards.refreshAmbientCards();
|
parts.cards.refreshAmbientCards();
|
||||||
}
|
scheduleViewGeometryRetry();
|
||||||
}
|
|
||||||
// Completion is never coalesced: subscribers must observe the final
|
// Completion is never coalesced: subscribers must observe the final
|
||||||
// loading state even if the last progress tick just happened.
|
// loading state even if the last progress tick just happened.
|
||||||
layerState._geoProgressNotifier?.finish();
|
layerState._geoProgressNotifier?.finish();
|
||||||
@@ -261,16 +267,17 @@ export function createGeometryQueue({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends records to the geometry queue (no progress tracking) and starts
|
* 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
|
* the batch timer if idle. Used by update()'s one-shot tiles-ready
|
||||||
* pass (records left `!groundResolved` by an enable-time drain that ran while
|
* completion pass and by enqueueViewGeometry, so both share the initial
|
||||||
* tiles were still streaming) so it shares the same stagger machinery as the
|
* load's stagger machinery. Never driven by a recurring timer.
|
||||||
* initial load. Fires at most once per enable — never on a recurring timer.
|
|
||||||
* @param {Object[]} records - Camera records needing geometry refresh.
|
* @param {Object[]} records - Camera records needing geometry refresh.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function enqueueGeometryRefresh(records) {
|
function enqueueGeometryRefresh(records) {
|
||||||
|
const queued = new Set(layerState._geoQueue);
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (!layerState._geoQueue.includes(record)) {
|
if (!queued.has(record)) {
|
||||||
|
queued.add(record);
|
||||||
layerState._geoQueue.push(record);
|
layerState._geoQueue.push(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,10 +290,10 @@ export function createGeometryQueue({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the initial staggered load: orders all records active-camera-first,
|
* Starts the initial staggered load over the geometry working set: active
|
||||||
* then by distance from the current viewer position (nearest first, so
|
* camera first, then by distance from the current viewer position (nearest
|
||||||
* cameras likely in view refine before off-screen ones), and exposes
|
* first), and exposes loaded/total progress through uiState()/getStats()
|
||||||
* loaded/total progress through uiState()/getStats() while running.
|
* while running. Cameras outside the view refine when they come into it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function startGeometryLoadQueue() {
|
function startGeometryLoadQueue() {
|
||||||
@@ -303,7 +310,7 @@ export function createGeometryQueue({
|
|||||||
const refLon = carto
|
const refLon = carto
|
||||||
? Cesium.Math.toDegrees(carto.longitude)
|
? Cesium.Math.toDegrees(carto.longitude)
|
||||||
: (active?.camera.lon ?? 0);
|
: (active?.camera.lon ?? 0);
|
||||||
const pending = layerState._records
|
const pending = geometryWorkingSet()
|
||||||
.filter((record) => record !== active)
|
.filter((record) => record !== active)
|
||||||
.map((record) => ({
|
.map((record) => ({
|
||||||
record,
|
record,
|
||||||
@@ -325,7 +332,134 @@ export function createGeometryQueue({
|
|||||||
);
|
);
|
||||||
layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0);
|
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 {
|
return {
|
||||||
|
enqueueViewGeometry,
|
||||||
|
withinViewBudget,
|
||||||
|
geometryWorkingSet,
|
||||||
stopGeometryLoadQueue,
|
stopGeometryLoadQueue,
|
||||||
createGeometryProgressNotifier,
|
createGeometryProgressNotifier,
|
||||||
processCctvGeometryQueueBatch,
|
processCctvGeometryQueueBatch,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
SUPPORT_KEYS,
|
SUPPORT_KEYS,
|
||||||
planeSupportPoints,
|
planeSupportPoints,
|
||||||
@@ -78,6 +79,14 @@ export function createGround({ state: layerState, services, parts, source }) {
|
|||||||
return record?.groundResolved?.[regime] === true;
|
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
|
* Ground altitude used for pure geometry recomputes: the given regime's
|
||||||
* cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor
|
* 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
|
* Task 5: applies a LATE-arriving ground-prior batch (init's bounded race
|
||||||
* lost — cold proxy cache / slow upstream). Pure recomputes only, no scene
|
* lost — cold proxy cache / slow upstream). Pure recomputes only, no scene
|
||||||
* queries:
|
* 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
|
* - 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
|
* - google-3d regime: records still awaiting a shared floor move from the
|
||||||
* catalog fallback onto the exact prior; records already holding a shared
|
* catalog fallback onto the exact prior; records already holding a shared
|
||||||
* mesh/DEM floor keep it untouched.
|
* mesh/DEM floor keep it untouched.
|
||||||
@@ -198,6 +210,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
|||||||
function applyLateGroundPriors(records, priors) {
|
function applyLateGroundPriors(records, priors) {
|
||||||
if (!Array.isArray(records) || !Array.isArray(priors)) return;
|
if (!Array.isArray(records) || !Array.isArray(priors)) return;
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
|
let moved = 0;
|
||||||
for (let i = 0; i < records.length && i < priors.length; i++) {
|
for (let i = 0; i < records.length && i < priors.length; i++) {
|
||||||
const record = records[i];
|
const record = records[i];
|
||||||
const prior = priors[i];
|
const prior = priors[i];
|
||||||
@@ -212,6 +225,24 @@ export function createGround({ state: layerState, services, parts, source }) {
|
|||||||
record.camera.absoluteHeightM =
|
record.camera.absoluteHeightM =
|
||||||
prior.ellipsoid + record.camera.mountHeightM;
|
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();
|
const regime = currentSurfaceRegime();
|
||||||
if (regime === 'terrain-globe') {
|
if (regime === 'terrain-globe') {
|
||||||
// Prior IS the resolution — re-latch onto the fresh value.
|
// Prior IS the resolution — re-latch onto the fresh value.
|
||||||
@@ -224,7 +255,9 @@ export function createGround({ state: layerState, services, parts, source }) {
|
|||||||
applied += 1;
|
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
|
* 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
|
* on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and
|
||||||
* no-ops):
|
* 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
|
* resolution — cached sample if that regime has one, else the Re:Earth
|
||||||
* prior (never blank, zero scene queries);
|
* prior (never blank, zero scene queries);
|
||||||
* 2. entering google-3d re-arms the one-shot tiles-ready completion latch so
|
* 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;
|
layerState._lastAppliedRegime = regime;
|
||||||
|
|
||||||
for (const record of layerState._records) {
|
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') {
|
if (regime === 'terrain-globe') {
|
||||||
// Prior IS the resolution — latch it (zero scene queries).
|
// Prior IS the resolution — latch it (zero scene queries).
|
||||||
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
|
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
|
||||||
@@ -353,6 +389,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
|||||||
resolveFootprintGround,
|
resolveFootprintGround,
|
||||||
groundPriorAltFor,
|
groundPriorAltFor,
|
||||||
isGroundResolved,
|
isGroundResolved,
|
||||||
|
needsGeometry,
|
||||||
groundAltFor,
|
groundAltFor,
|
||||||
rearmGroundResolution,
|
rearmGroundResolution,
|
||||||
resolveCommittedGroundAnchor,
|
resolveCommittedGroundAnchor,
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export function createCctvLayer({ services, source }) {
|
|||||||
processGeometryBatch: parts.geometryQueue.processGeometryBatch,
|
processGeometryBatch: parts.geometryQueue.processGeometryBatch,
|
||||||
hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals,
|
hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals,
|
||||||
refreshCoverageStyles: parts.rendering.refreshCoverageStyles,
|
refreshCoverageStyles: parts.rendering.refreshCoverageStyles,
|
||||||
|
refreshHorizonCulling: parts.rendering.refreshHorizonCulling,
|
||||||
|
_viewRecordIdsForTest: () =>
|
||||||
|
state._viewRecords.map((record) => record.camera.id),
|
||||||
clearProbeClampOnDeactivation:
|
clearProbeClampOnDeactivation:
|
||||||
parts.geometry.clearProbeClampOnDeactivation,
|
parts.geometry.clearProbeClampOnDeactivation,
|
||||||
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
|
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
|
||||||
|
|||||||
@@ -32,13 +32,22 @@ export function createIngestion({
|
|||||||
// projectionTilesReady() is false while a (hidden) Google tileset
|
// projectionTilesReady() is false while a (hidden) Google tileset
|
||||||
// exists, so this latch effectively fires for the google-3d regime —
|
// exists, so this latch effectively fires for the google-3d regime —
|
||||||
// terrain-globe records resolve from the prior in their drain pass.
|
// terrain-globe records resolve from the prior in their drain pass.
|
||||||
const unresolved = layerState._records.filter(
|
const unresolved = parts.geometryQueue
|
||||||
(record) => !parts.ground.isGroundResolved(record),
|
.geometryWorkingSet()
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
!parts.ground.isGroundResolved(record) &&
|
||||||
|
parts.geometryQueue.withinViewBudget(record),
|
||||||
);
|
);
|
||||||
if (unresolved.length)
|
if (unresolved.length)
|
||||||
parts.geometryQueue.enqueueGeometryRefresh(unresolved);
|
parts.geometryQueue.enqueueGeometryRefresh(unresolved);
|
||||||
}
|
}
|
||||||
await parts.health.syncHealthState();
|
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.navigation.maybeAutoHop(now);
|
||||||
parts.presentation.notifyListeners();
|
parts.presentation.notifyListeners();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ import {
|
|||||||
CAMERA_ICON,
|
CAMERA_ICON,
|
||||||
IDLE_CAMERA_COLOR,
|
IDLE_CAMERA_COLOR,
|
||||||
CALIBRATION_RANGE_FLOOR_M,
|
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';
|
} from './policy.js';
|
||||||
|
|
||||||
|
const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180);
|
||||||
|
|
||||||
export function createLifecycle({
|
export function createLifecycle({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
services,
|
services,
|
||||||
@@ -22,15 +28,94 @@ export function createLifecycle({
|
|||||||
const { onFocusTargetAppear } = services.focus;
|
const { onFocusTargetAppear } = services.focus;
|
||||||
const { releaseContinuousRender } = services.render;
|
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. */
|
/** Resets all module-scoped runtime state to initial values. */
|
||||||
|
|
||||||
function clearRuntimeState() {
|
function clearRuntimeState() {
|
||||||
parts.geometryQueue.stopGeometryLoadQueue();
|
parts.geometryQueue.stopGeometryLoadQueue();
|
||||||
|
stopViewMotionTimer();
|
||||||
// Idempotent — also covers a re-init without a prior destroy().
|
// Idempotent — also covers a re-init without a prior destroy().
|
||||||
parts.cards.teardownAmbientCards();
|
parts.cards.teardownAmbientCards();
|
||||||
parts.projection.clearProjectionOverlay();
|
parts.projection.clearProjectionOverlay();
|
||||||
layerState._records = [];
|
layerState._records = [];
|
||||||
layerState._recordById = new Map();
|
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._healthById = new Map();
|
||||||
layerState._count = 0;
|
layerState._count = 0;
|
||||||
layerState._lastUpdate = null;
|
layerState._lastUpdate = null;
|
||||||
@@ -71,6 +156,13 @@ export function createLifecycle({
|
|||||||
layerState._calibrationById = parts.calibration.loadCalibrationStore();
|
layerState._calibrationById = parts.calibration.loadCalibrationStore();
|
||||||
|
|
||||||
layerState._billboards = new Cesium.BillboardCollection();
|
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);
|
layerState._viewer.scene.primitives.add(layerState._billboards);
|
||||||
registerSpriteCollection('cctv', 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
|
// 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
|
// the billboards' always-on-top depth setting) + one initial pass so the
|
||||||
// first paint is already culled.
|
// 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) {
|
if (!layerState._horizonCullListener) {
|
||||||
// Ambient cards piggyback the same settle event: moveEnd-driven
|
// Ambient cards piggyback the same settle event: moveEnd-driven
|
||||||
// reselection only, never per frame (refreshAmbientCards no-ops while
|
// reselection only, never per frame (refreshAmbientCards no-ops while
|
||||||
// the layer is disabled).
|
// the layer is disabled).
|
||||||
layerState._horizonCullListener = () => {
|
layerState._horizonCullListener = () => {
|
||||||
layerState._cameraMoving = false;
|
layerState._cameraMoving = false;
|
||||||
parts.rendering.refreshHorizonCulling();
|
stopViewMotionTimer();
|
||||||
parts.cards.refreshAmbientCards();
|
settleView();
|
||||||
};
|
};
|
||||||
layerState._viewer.camera.moveEnd.addEventListener(
|
layerState._viewer.camera.moveEnd.addEventListener(
|
||||||
layerState._horizonCullListener,
|
layerState._horizonCullListener,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!layerState._moveStartListener) {
|
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._moveStartListener = () => {
|
||||||
layerState._cameraMoving = true;
|
layerState._cameraMoving = true;
|
||||||
|
// enable() starts it for a move already under way.
|
||||||
|
if (layerState._enabled) startViewMotionTimer();
|
||||||
};
|
};
|
||||||
layerState._viewer.camera.moveStart.addEventListener(
|
layerState._viewer.camera.moveStart.addEventListener(
|
||||||
layerState._moveStartListener,
|
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();
|
parts.rendering.refreshHorizonCulling();
|
||||||
|
|
||||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||||
@@ -325,6 +465,12 @@ export function createLifecycle({
|
|||||||
);
|
);
|
||||||
|
|
||||||
await parts.health.syncHealthState(true);
|
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.rendering.refreshCoverageStyles();
|
||||||
parts.presentation.notifyListeners();
|
parts.presentation.notifyListeners();
|
||||||
restoreSpriteOrder(layerState._viewer);
|
restoreSpriteOrder(layerState._viewer);
|
||||||
@@ -370,6 +516,9 @@ export function createLifecycle({
|
|||||||
parts.projection.ensureProjectionRuntime(activeRecord);
|
parts.projection.ensureProjectionRuntime(activeRecord);
|
||||||
parts.frames.refreshProjectionImage(activeRecord, true);
|
parts.frames.refreshProjectionImage(activeRecord, true);
|
||||||
}
|
}
|
||||||
|
parts.rendering.refreshHorizonCulling();
|
||||||
|
// Re-enabled mid-flight: moveStart has already fired.
|
||||||
|
if (layerState._cameraMoving) startViewMotionTimer();
|
||||||
parts.geometryQueue.startGeometryLoadQueue();
|
parts.geometryQueue.startGeometryLoadQueue();
|
||||||
parts.rendering.refreshCoverageStyles();
|
parts.rendering.refreshCoverageStyles();
|
||||||
parts.projection.startProjectionLoop();
|
parts.projection.startProjectionLoop();
|
||||||
@@ -402,10 +551,14 @@ export function createLifecycle({
|
|||||||
layerState._removeFocusAppearListener = null;
|
layerState._removeFocusAppearListener = null;
|
||||||
parts.projection.stopProjectionLoop();
|
parts.projection.stopProjectionLoop();
|
||||||
parts.geometryQueue.stopGeometryLoadQueue();
|
parts.geometryQueue.stopGeometryLoadQueue();
|
||||||
|
stopViewMotionTimer();
|
||||||
// Ambient cards tear down COMPLETELY on disable (owner design point 6):
|
// Ambient cards tear down COMPLETELY on disable (owner design point 6):
|
||||||
// source entries, pacer timer, in-flight handlers, and caches.
|
// source entries, pacer timer, in-flight handlers, and caches.
|
||||||
parts.cards.teardownAmbientCards();
|
parts.cards.teardownAmbientCards();
|
||||||
parts.rendering.hideCctvVisuals();
|
parts.rendering.hideCctvVisuals();
|
||||||
|
// Nothing is in view while the layer is off.
|
||||||
|
layerState._viewRecords = [];
|
||||||
|
layerState._cameraIndexCache = null;
|
||||||
parts.presentation.notifyListeners();
|
parts.presentation.notifyListeners();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -443,6 +596,13 @@ export function createLifecycle({
|
|||||||
);
|
);
|
||||||
layerState._moveStartListener = null;
|
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) {
|
if (layerState._gizmo) {
|
||||||
layerState._gizmo.destroy();
|
layerState._gizmo.destroy();
|
||||||
layerState._gizmo = null;
|
layerState._gizmo = null;
|
||||||
|
|||||||
@@ -60,6 +60,45 @@ export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300;
|
|||||||
|
|
||||||
export const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10;
|
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
|
// 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.
|
// re-fill a 1080p canvas on every frame while a feed image is still loading.
|
||||||
|
|
||||||
|
|||||||
@@ -274,14 +274,53 @@ export function createPresentation({
|
|||||||
},
|
},
|
||||||
activeCameraId: activeId,
|
activeCameraId: activeId,
|
||||||
activeCamera: active ? getPublicCameraState(active, activeId) : null,
|
activeCamera: active ? getPublicCameraState(active, activeId) : null,
|
||||||
cameras: layerState._records.map((record) =>
|
cameraIndex: cameraIndex(),
|
||||||
getPublicCameraState(record, activeId),
|
|
||||||
),
|
|
||||||
summary: buildSummaryText(),
|
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;
|
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. */
|
/** Dispatches the current UI state to all registered subscriber callbacks. */
|
||||||
|
|
||||||
function notifyListeners() {
|
function notifyListeners() {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
|
||||||
import { isHeadingEstimated } from './headingConfidence.js';
|
import { isHeadingEstimated } from './headingConfidence.js';
|
||||||
import {
|
import {
|
||||||
ACTIVE_CAMERA_COLOR,
|
ACTIVE_CAMERA_COLOR,
|
||||||
@@ -11,8 +10,21 @@ import {
|
|||||||
ACTIVE_COVERAGE_EDGE,
|
ACTIVE_COVERAGE_EDGE,
|
||||||
IDLE_COVERAGE_EDGE_MUTED,
|
IDLE_COVERAGE_EDGE_MUTED,
|
||||||
ACTIVE_COVERAGE_EDGE_DEPTHFAIL,
|
ACTIVE_COVERAGE_EDGE_DEPTHFAIL,
|
||||||
|
VIEW_CELL_PX,
|
||||||
|
VIEW_ICON_MARGIN,
|
||||||
|
VIEW_MIN_CELL_M,
|
||||||
} from './policy.js';
|
} 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({
|
export function createRendering({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
services,
|
services,
|
||||||
@@ -138,24 +150,186 @@ export function createRendering({
|
|||||||
* (Google-3D regime) nothing writes far-side depth, and the billboards are now
|
* (Google-3D regime) nothing writes far-side depth, and the billboards are now
|
||||||
* always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge
|
* always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge
|
||||||
* fix), so without this pass London's cluster would shine through the planet
|
* 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
|
* from a US viewpoint.
|
||||||
* camera.moveEnd + init only (event-driven — no steady-state work).
|
*
|
||||||
|
* 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() {
|
function refreshHorizonCulling() {
|
||||||
if (
|
if (
|
||||||
|
!layerState._enabled ||
|
||||||
!layerState._viewer ||
|
!layerState._viewer ||
|
||||||
layerState._viewer.isDestroyed() ||
|
layerState._viewer.isDestroyed() ||
|
||||||
!layerState._records.length
|
!layerState._records.length
|
||||||
)
|
)
|
||||||
return;
|
return false;
|
||||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
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) {
|
for (const record of layerState._records) {
|
||||||
const bb = record.billboard;
|
const bb = record.billboard;
|
||||||
if (!bb) continue;
|
if (!bb) continue;
|
||||||
const visible = occluder.isPointVisible(bb.position);
|
const visible = record.viewPass === pass;
|
||||||
if (bb.show !== visible) bb.show = visible;
|
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 activeId = activeRecord?.camera.id || null;
|
||||||
const coverageVisible =
|
const coverageVisible =
|
||||||
parts.geometry.buildCoverageVisibleSet(activeRecord);
|
parts.geometry.buildCoverageVisibleSet(activeRecord);
|
||||||
|
// Drawn coverage needs geometry too (geometryQueue's working set).
|
||||||
|
layerState._coverageVisibleIds = coverageVisible;
|
||||||
const coverageOn = layerState._coverageMode !== 'off';
|
const coverageOn = layerState._coverageMode !== 'off';
|
||||||
const viewshedOn = layerState._coverageMode === 'viewshed';
|
const viewshedOn = layerState._coverageMode === 'viewshed';
|
||||||
if (coverageOn) {
|
if (coverageOn) {
|
||||||
@@ -215,8 +391,13 @@ export function createRendering({
|
|||||||
for (const record of layerState._records) {
|
for (const record of layerState._records) {
|
||||||
const isActive = record.camera.id === activeId;
|
const isActive = record.camera.id === activeId;
|
||||||
if (record.billboard) {
|
if (record.billboard) {
|
||||||
record.billboard.color = cameraColorFor(record);
|
// Unchanged writes still dirty the collection, which then rewrites
|
||||||
record.billboard.scale = isActive ? 1.25 : 1.0;
|
// 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
|
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
|
||||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ export function createSelection({
|
|||||||
void parts.ground.resolveFootprintGround(record);
|
void parts.ground.resolveFootprintGround(record);
|
||||||
record.activationDone = true;
|
record.activationDone = true;
|
||||||
parts.rendering.refreshCoverageStyles();
|
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
|
// The newly active camera leaves the ambient ring (its monitor plane takes
|
||||||
// over); the freed slot re-fills on this same pass.
|
// over); the freed slot re-fills on this same pass.
|
||||||
parts.cards.refreshAmbientCards();
|
parts.cards.refreshAmbientCards();
|
||||||
@@ -171,7 +176,9 @@ export function createSelection({
|
|||||||
parts.ground.groundAltFor(previous),
|
parts.ground.groundAltFor(previous),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
parts.rendering.refreshHorizonCulling();
|
||||||
parts.rendering.refreshCoverageStyles();
|
parts.rendering.refreshCoverageStyles();
|
||||||
|
parts.geometryQueue.enqueueViewGeometry();
|
||||||
parts.cards.refreshAmbientCards();
|
parts.cards.refreshAmbientCards();
|
||||||
layerState._gizmo?.refresh();
|
layerState._gizmo?.refresh();
|
||||||
parts.presentation.notifyListeners();
|
parts.presentation.notifyListeners();
|
||||||
|
|||||||
@@ -76,6 +76,63 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._scratchFocusScreen = new Cesium.Cartesian2();
|
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).
|
// Staggered geometry-load queue state (see startGeometryLoadQueue).
|
||||||
|
|
||||||
layerState._geoQueue = [];
|
layerState._geoQueue = [];
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
* @param {boolean} [options.enabled=true] Layer enabled state.
|
* @param {boolean} [options.enabled=true] Layer enabled state.
|
||||||
* @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode.
|
* @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode.
|
||||||
* @param {boolean} [options.showProjection=false] Projection visibility.
|
* @param {boolean} [options.showProjection=false] Projection visibility.
|
||||||
|
* @param {Map<string, Object>} [options.healthById] Health rows by camera id.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
coverageMode = 'on',
|
coverageMode = 'on',
|
||||||
showProjection = false,
|
showProjection = false,
|
||||||
|
healthById = new Map(),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
layerState._viewer = viewer;
|
layerState._viewer = viewer;
|
||||||
layerState._records = Array.isArray(records) ? records : [];
|
layerState._records = Array.isArray(records) ? records : [];
|
||||||
@@ -108,6 +110,11 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
'on',
|
'on',
|
||||||
);
|
);
|
||||||
layerState._showProjection = !!showProjection;
|
layerState._showProjection = !!showProjection;
|
||||||
|
layerState._healthById = healthById;
|
||||||
|
layerState._viewRecords = [];
|
||||||
|
layerState._cameraIndexCache = null;
|
||||||
|
layerState._coverageVisibleIds = new Set();
|
||||||
|
layerState._neighborCountCache = null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
_pushAmbientCardEntriesForTest,
|
_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 () => {
|
this.listen(this._cctvSelect, 'change', async () => {
|
||||||
const generation = ++this._actionGeneration;
|
const generation = ++this._actionGeneration;
|
||||||
const activeId = this._cctvState?.activeCameraId;
|
const activeId = this._cctvState?.activeCameraId;
|
||||||
@@ -72,9 +78,9 @@ export function _initCctvPanel() {
|
|||||||
(activeId && activeId !== this._cctvState?.activeCameraId)
|
(activeId && activeId !== this._cctvState?.activeCameraId)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
// Picking a camera from the dropdown flies to it. The catalog spans
|
// Picking a camera from the dropdown flies to it. The dropdown lists the
|
||||||
// three metros, so a bare selection used to leave the view in the old
|
// cameras in view (and the active camera, which may be far away), so a
|
||||||
// city with a camera active thousands of km away.
|
// bare selection could leave the view somewhere else.
|
||||||
this.actions.runExplicitFocus(
|
this.actions.runExplicitFocus(
|
||||||
() => (this.cctv.selectCamera(cameraId) ? cameraId : null),
|
() => (this.cctv.selectCamera(cameraId) ? cameraId : null),
|
||||||
(selectedId) => this.cctv.focusCamera(selectedId, 2.2),
|
(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);
|
||||||
|
});
|
||||||
+78
-12
@@ -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) {
|
export function _renderCctvState(state) {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
if (
|
if (
|
||||||
@@ -26,7 +35,13 @@ export function _renderCctvState(state) {
|
|||||||
this._calibrationEdit?.(false);
|
this._calibrationEdit?.(false);
|
||||||
}
|
}
|
||||||
this._cctvState = state || null;
|
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 enabled = !!state?.enabled && !!this.actions.isEnabled();
|
||||||
const activeId = state?.activeCameraId || '';
|
const activeId = state?.activeCameraId || '';
|
||||||
const activeCamera = state?.activeCamera || null;
|
const activeCamera = state?.activeCamera || null;
|
||||||
@@ -59,28 +74,79 @@ export function _renderCctvState(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this._cctvSelect) {
|
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 =
|
const shouldRebuild =
|
||||||
this._cctvSelect.options.length !== cameras.length ||
|
!picking &&
|
||||||
|
(this._cctvSelectSource !== cameras ||
|
||||||
|
this._cctvSelectOutside !== outside) &&
|
||||||
|
(this._cctvSelectOutside !== outside ||
|
||||||
|
this._cctvSelect.options.length !==
|
||||||
|
cameras.length + (outside ? 1 : 0) ||
|
||||||
cameras.some(
|
cameras.some(
|
||||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
||||||
);
|
));
|
||||||
|
if (!picking) {
|
||||||
|
this._cctvSelectSource = cameras;
|
||||||
|
this._cctvSelectOutside = outside;
|
||||||
|
}
|
||||||
if (shouldRebuild) {
|
if (shouldRebuild) {
|
||||||
this._cctvSelect.innerHTML = '';
|
const options = document.createDocumentFragment();
|
||||||
for (const camera of cameras) {
|
for (const camera of cameras) {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = camera.id;
|
option.value = camera.id;
|
||||||
option.textContent = `${camera.city} · ${camera.name}`;
|
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;
|
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 (
|
if (
|
||||||
|
picking &&
|
||||||
activeId &&
|
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;
|
this._cctvSelect.value = activeId;
|
||||||
} else if (!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,
|
this._cctvNextBtn,
|
||||||
]) {
|
]) {
|
||||||
if (!btn) continue;
|
if (!btn) continue;
|
||||||
btn.disabled = !enabled || cameras.length === 0;
|
btn.disabled = !enabled || catalogCount === 0;
|
||||||
}
|
}
|
||||||
if (this._cctvFocusBtn) {
|
if (this._cctvFocusBtn) {
|
||||||
this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId;
|
this._cctvFocusBtn.disabled = !enabled || catalogCount === 0 || !activeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._cctvCoverageBtn) {
|
if (this._cctvCoverageBtn) {
|
||||||
@@ -180,15 +246,15 @@ export function _renderCctvState(state) {
|
|||||||
status: `${credit}${statusMsg}`,
|
status: `${credit}${statusMsg}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (cameras.length > 0) {
|
} else if (catalogCount > 0) {
|
||||||
this._cctvMeta.textContent = enabled
|
this._cctvMeta.textContent = enabled
|
||||||
? tn(
|
? tn(
|
||||||
cameras.length,
|
catalogCount,
|
||||||
'{count} camera loaded · click a camera to activate',
|
'{count} camera loaded · click a camera to activate',
|
||||||
'{count} cameras loaded · click a camera to activate',
|
'{count} cameras loaded · click a camera to activate',
|
||||||
)
|
)
|
||||||
: tn(
|
: tn(
|
||||||
cameras.length,
|
catalogCount,
|
||||||
'{count} camera loaded · enable CCTV to activate',
|
'{count} camera loaded · enable CCTV to activate',
|
||||||
'{count} cameras 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 {
|
return {
|
||||||
activeCameraId: ui.activeCameraId || null,
|
activeCameraId: ui.activeCameraId || null,
|
||||||
activeCamera: ui.activeCamera?.name || ui.activeCamera?.id || null,
|
activeCamera: ui.activeCamera?.name || ui.activeCamera?.id || null,
|
||||||
cameraCount: Array.isArray(ui.cameras)
|
// `count`, not `cameras.length`: reading `cameras` builds every camera.
|
||||||
? ui.cameras.length
|
cameraCount: Number.isFinite(ui.count) ? ui.count : 0,
|
||||||
: ui.count || 0,
|
|
||||||
showCoverage: !!ui.showCoverage,
|
showCoverage: !!ui.showCoverage,
|
||||||
coverageMode: ui.coverageMode || (ui.showCoverage ? 'on' : 'off'),
|
coverageMode: ui.coverageMode || (ui.showCoverage ? 'on' : 'off'),
|
||||||
showProjection: !!ui.showProjection,
|
showProjection: !!ui.showProjection,
|
||||||
|
|||||||
Reference in new issue
Block a user