refactor(installations): use globe camera contract

This commit is contained in:
German Khokhlov committed 2026-10-28 09:50:17 +04:00
1 parent c58bc9864b
commit f4f0088814
6 files changed
+100 -17

No files matched your search

+7 -8
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { installationFeedback } from '../../data/installationFeedback.js'; import { installationFeedback } from '../../data/installationFeedback.js';
import { import {
@@ -39,7 +38,7 @@ export function createControls({ state: layerState, services, parts, source }) {
* only area that answers "what is near this contact". The window moves * only area that answers "what is near this contact". The window moves
* only after the subject travels ANCHOR_REFRESH_M, and cached tiles make * only after the subject travels ANCHOR_REFRESH_M, and cached tiles make
* that cheap. Passing null returns the layer to viewport loading. * that cheap. Passing null returns the layer to viewport loading.
* @param {Cesium.Cartesian3|{latitude:number, longitude:number}|null} position * @param {object|{latitude:number, longitude:number}|null} position
* Subject position. * Subject position.
* @returns {boolean} Whether a new load was scheduled. * @returns {boolean} Whether a new load was scheduled.
*/ */
@@ -145,19 +144,19 @@ export function createControls({ state: layerState, services, parts, source }) {
*/ */
focusById(id) { focusById(id) {
const record = layerState.recordById.get(String(id)); const record = layerState.recordById.get(String(id));
if (!record || !layerState.viewer) return false; if (!record || !layerState.engine?.camera?.flyToSphere) return false;
// No camera flight without a real selection: a flight plus a stale subject // No camera flight without a real selection: a flight plus a stale subject
// reads as success to Context navigation and strands NEXT on this item. // reads as success to Context navigation and strands NEXT on this item.
if (!parts.selection.selectRecord(record.id)) return false; if (!parts.selection.selectRecord(record.id)) return false;
layerState.viewer.camera.flyToBoundingSphere( layerState.engine.camera.flyToSphere(
new Cesium.BoundingSphere( {
geo.cartesian3.fromDegrees( center: geo.cartesian3.fromDegrees(
record.longitude, record.longitude,
record.latitude, record.latitude,
parts.rendering.installationSurfaceHeightM(record), parts.rendering.installationSurfaceHeightM(record),
), ),
18000, radius: 18000,
), },
{ duration: 1.4 }, { duration: 1.4 },
); );
return true; return true;
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { createControls } from './controls.js';
test('focus frames a selected installation through the globe engine camera', () => {
const record = { id: 'osm:node:12', latitude: 70, longitude: 179.8 };
let flight = null;
const state = {
engine: {
camera: {
flyToSphere: (sphere, options) => {
flight = { sphere, options };
},
},
},
recordById: new Map([[record.id, record]]),
};
const controls = createControls({
state,
services: {},
parts: {
selection: { selectRecord: (id) => id === record.id },
rendering: { installationSurfaceHeightM: () => 126 },
},
source: {},
}).methods;
assert.equal(controls.focusById(record.id), true);
assert.deepEqual(
flight.sphere.center,
geo.cartesian3.fromDegrees(179.8, 70, 126),
);
assert.equal(flight.sphere.radius, 18000);
assert.deepEqual(flight.options, { duration: 1.4 });
});
+6 -4
View File
@@ -15,19 +15,20 @@ export function createLifecycle({
const { clearSelectedEntityContextForLayer } = services.context; const { clearSelectedEntityContextForLayer } = services.context;
const methods = { const methods = {
init(viewer) { init(viewer, { engine = null } = {}) {
layerState.viewer = viewer; layerState.viewer = viewer;
layerState.engine = engine;
layerState.dataSource = new Cesium.CustomDataSource( layerState.dataSource = new Cesium.CustomDataSource(
'military-installations', 'military-installations',
); );
viewer.dataSources.add(layerState.dataSource); viewer.dataSources.add(layerState.dataSource);
const camera = engine?.camera ?? viewer.camera;
const cameraChanged = () => { const cameraChanged = () => {
if (!layerState.contextAnchor) parts.viewport.scheduleLoad(); if (!layerState.contextAnchor) parts.viewport.scheduleLoad();
}; };
layerState.moveEndRemove = layerState.moveEndRemove = camera.moveEnd.addEventListener(cameraChanged);
viewer.camera.moveEnd.addEventListener(cameraChanged);
layerState.changedRemove = layerState.changedRemove =
viewer.camera.changed?.addEventListener(cameraChanged); camera.changed?.addEventListener(cameraChanged);
parts.selection.installInteraction(viewer); parts.selection.installInteraction(viewer);
}, },
@@ -100,6 +101,7 @@ export function createLifecycle({
if (layerState.dataSource && viewer) if (layerState.dataSource && viewer)
viewer.dataSources.remove(layerState.dataSource, true); viewer.dataSources.remove(layerState.dataSource, true);
layerState.dataSource = null; layerState.dataSource = null;
layerState.engine = null;
}, },
}; };
@@ -189,3 +189,45 @@ test('wide views refresh screen decluttering when the globe rectangle stays unch
t.mock.timers.tick(200); t.mock.timers.tick(200);
assert.equal(loads, 2, 'a different continent needs fresh projected labels'); assert.equal(loads, 2, 'a different continent needs fresh projected labels');
}); });
test('the engine camera drives installation viewport requests without a viewer', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const rectangle = {
west: Cesium.Math.toRadians(179),
south: Cesium.Math.toRadians(-12),
east: Cesium.Math.toRadians(-178),
north: Cesium.Math.toRadians(16),
};
const state = {
enabled: true,
viewer: null,
engine: {
camera: {
viewRectangle: () => rectangle,
pose: () => ({
position: { x: 1, y: 2, z: 3 },
direction: { x: -1, y: 0, z: 0 },
}),
},
},
};
const loads = [];
const parts = {
ingestion: {
loadInstallations() {
loads.push(parts.viewport.loadArea(null));
},
},
};
parts.viewport = createViewport({ state, parts, services: {}, source: {} });
parts.viewport.scheduleLoad();
t.mock.timers.tick(200);
assert.equal(loads.length, 1);
assert.ok(Math.abs(loads[0].box.south + 12) < 1e-10);
assert.equal(loads[0].box.west, 179);
assert.equal(loads[0].box.north, 16);
assert.equal(loads[0].box.east, -178);
assert.equal(loads[0].coverage.kind, 'viewport');
});
+1
View File
@@ -9,6 +9,7 @@ export function createState({ services }) {
Object.assign(state, { Object.assign(state, {
viewer: null, viewer: null,
engine: null,
dataSource: null, dataSource: null,
enabled: false, enabled: false,
records: [], records: [],
+8 -5
View File
@@ -23,9 +23,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
} }
function viewportBox(viewer) { function viewportBox(viewer) {
const rectangle = viewer?.camera?.computeViewRectangle( const rectangle =
viewer.scene.globe.ellipsoid, layerState.engine?.camera?.viewRectangle?.() ??
); viewer?.camera?.computeViewRectangle(viewer.scene.globe.ellipsoid);
if (!rectangle) return null; if (!rectangle) return null;
const south = geo.math.toDegrees(rectangle.south); const south = geo.math.toDegrees(rectangle.south);
const north = geo.math.toDegrees(rectangle.north); const north = geo.math.toDegrees(rectangle.north);
@@ -89,14 +89,17 @@ export function createViewport({ state: layerState, services, parts, source }) {
function scheduleLoad() { function scheduleLoad() {
if (!layerState.enabled) return; if (!layerState.enabled) return;
const { box, coverage } = loadArea(layerState.viewer); const { box, coverage } = loadArea(layerState.viewer);
const camera = layerState.viewer.camera; const pose = layerState.engine?.camera?.pose?.();
const camera = layerState.viewer?.camera;
const position = pose?.position ?? camera?.positionWC;
const direction = pose?.direction ?? camera?.directionWC;
const widePose = const widePose =
coverage.kind === 'viewport' && coverage.kind === 'viewport' &&
box && box &&
(box.east < box.west || (box.east < box.west ||
box.east - box.west > 10 || box.east - box.west > 10 ||
box.north - box.south > 10) box.north - box.south > 10)
? [camera.positionWC, camera.directionWC] ? [position, direction]
.map((v) => .map((v) =>
v ? [v.x, v.y, v.z].map((n) => n.toFixed(4)).join(',') : '', v ? [v.x, v.y, v.z].map((n) => n.toFixed(4)).join(',') : '',
) )