mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Draw Mapped Installations, Global Context and the infrastructure layers through one display each
- Mapped Installations: one display holds the single copy of the logic the Cesium and Navara renderers each had (renderable records, painting, the selection fill, floors, named markers, selection). Discs are point movers, outlines ground corridors and the selection fill a ground polygon. Cesium's behaviour wins where they drifted: named areas draw their outlines (Navara dropped them), discs sit under the moving contacts, and outlines and the fill pick their site. - Global Context: the range ring is a halo and a core line on engine.lines and the awareness logic no longer imports Cesium. The animated pulse disc, which no primitive draws yet, sits behind one seam, render/pulseDisc.js, with a Cesium and a Navara backend. - Datacenters and dams: one display on movers, lines and ground sets with Cesium's LOD, motion probe, floor sampling and card cohort; localGeojsonCore.js keeps only the engine-free card and record code (1381 to 329 lines). Coupling: layer renderer lines fall from 1011/4832 to 351/3349 (Cesium/Navara), `viewer` lines from 1745 to 1591, ScreenSpaceEventHandler files from 8 to 6, Navara ground passes from 5 to 4.
This commit is contained in:
1 parent
e18f12d34c
commit
30c86a6001
61 files changed
+5510
-7104
No files matched your search
@@ -34,6 +34,9 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||
// cesium only in render/cesium*.js, three and Navara only in
|
||||
// render/navara*.js. The list grows with each layer moved onto renderers.
|
||||
const RENDERED_LAYER_DIRS = [
|
||||
'src/layers/infrastructure/',
|
||||
'src/layers/installations/',
|
||||
'src/layers/awareness/',
|
||||
'src/layers/droneAirspace/',
|
||||
'src/layers/cyclones/',
|
||||
'src/layers/ghostMaps/',
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 1011,
|
||||
"navaraLayerRendererLines": 4832,
|
||||
"cesiumImportFiles": 53,
|
||||
"viewerLines": 1745,
|
||||
"navaraKindChecks": 9,
|
||||
"cesiumLayerRendererLines": 351,
|
||||
"navaraLayerRendererLines": 3349,
|
||||
"cesiumImportFiles": 51,
|
||||
"viewerLines": 1591,
|
||||
"navaraKindChecks": 8,
|
||||
"engineSniffCalls": 14,
|
||||
"engineExtensionReads": 19,
|
||||
"groundEffectPasses": 5,
|
||||
"engineExtensionReads": 18,
|
||||
"groundEffectPasses": 4,
|
||||
"cesiumSdkLoads": 6,
|
||||
"screenSpaceHandlerFiles": 8,
|
||||
"engineNameLines": 108
|
||||
"screenSpaceHandlerFiles": 6,
|
||||
"engineNameLines": 101
|
||||
},
|
||||
"measuredAt": "after the ground layers (earthquakes, air quality, GNSS, perimeters, drone airspace, FIRMS, GhostMaps, cyclones, cables), 2026-11-07"
|
||||
"measuredAt": "after the installations, Global Context and infrastructure displays, 2026-11-07"
|
||||
}
|
||||
@@ -1231,9 +1231,6 @@
|
||||
"src/layers/shared/render/navaraMoverBank.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
"src/layers/installations/render/cesium/lifecycle.js",
|
||||
"src/layers/installations/render/cesium/rendering.js",
|
||||
"src/layers/installations/render/cesium/selection.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/bikeshare/presentation.test.mjs",
|
||||
@@ -1411,7 +1408,6 @@
|
||||
"scripts/qa-w2-imagery.mjs",
|
||||
"src/globe/navara/postSamplingPass.test.mjs",
|
||||
"src/globe/navara/emptySceneRender.test.mjs",
|
||||
"src/layers/installations/render/navaraStencilBatch.test.mjs",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
@@ -1626,5 +1622,23 @@
|
||||
"src/layers/perimeters/render/display.test.mjs",
|
||||
"src/layers/submarineCables/render/display.js",
|
||||
"src/layers/submarineCables/render/display.test.mjs",
|
||||
"src/testSupport/groundDisplayEngines.mjs"
|
||||
"src/testSupport/groundDisplayEngines.mjs",
|
||||
"src/layers/awareness/render/cesiumPulses.js",
|
||||
"src/layers/awareness/render/display.js",
|
||||
"src/layers/awareness/render/display.test.mjs",
|
||||
"src/layers/awareness/render/pulseDisc.js",
|
||||
"src/layers/infrastructure/render/display.js",
|
||||
"src/layers/infrastructure/render/display.test.mjs",
|
||||
"src/layers/installations/render/display.js",
|
||||
"src/layers/installations/render/display.test.mjs",
|
||||
"src/layers/installations/render/displayHarness.mjs",
|
||||
"src/layers/installations/render/namedMarkers.js",
|
||||
"src/layers/installations/render/style.js",
|
||||
"src/layers/installations/selection.test.mjs",
|
||||
"src/layers/installations/selectionFill.test.mjs",
|
||||
"src/layers/installations/lifecycle.test.mjs",
|
||||
"src/layers/installations/navigation.test.mjs",
|
||||
"src/layers/infrastructure/index.js",
|
||||
"src/layers/infrastructure/session.test.mjs",
|
||||
"src/data/localGeojson.test.mjs"
|
||||
]
|
||||
@@ -11,9 +11,14 @@
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/infrastructureOverlayEntry.js",
|
||||
"src/sources/infrastructureData.js"
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/geo/",
|
||||
"src/layers/infrastructure/index.js",
|
||||
"src/layers/infrastructure/render/display.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/sources/requestDeadline.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": []
|
||||
},
|
||||
"application": {
|
||||
"exports": ["./application"],
|
||||
@@ -1789,12 +1794,7 @@
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/layers/installations/classLabel.js",
|
||||
"src/layers/installations/render/cesium/lifecycle.js",
|
||||
"src/layers/installations/render/cesium.js",
|
||||
"src/layers/installations/render/cesium/rendering.js",
|
||||
"src/layers/installations/render/cesium/selection.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/installations/render/cesium/namedMarkers.js",
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
@@ -1826,7 +1826,6 @@
|
||||
"src/layers/awareness/policy.js",
|
||||
"src/layers/awareness/presentation.js",
|
||||
"src/layers/awareness/queries.js",
|
||||
"src/layers/awareness/render/cesium.js",
|
||||
"src/layers/awareness/rendering.js",
|
||||
"src/layers/awareness/state.js",
|
||||
"src/layers/awareness/subject.js",
|
||||
@@ -2623,7 +2622,6 @@
|
||||
"src/layers/awareness/policy.js",
|
||||
"src/layers/awareness/presentation.js",
|
||||
"src/layers/awareness/queries.js",
|
||||
"src/layers/awareness/render/cesium.js",
|
||||
"src/layers/awareness/rendering.js",
|
||||
"src/layers/awareness/state.js",
|
||||
"src/layers/awareness/subject.js",
|
||||
@@ -3414,15 +3412,10 @@
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/installations/render/cesium/lifecycle.js",
|
||||
"src/layers/installations/render/cesium.js",
|
||||
"src/layers/installations/render/cesium/rendering.js",
|
||||
"src/layers/installations/render/cesium/selection.js",
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/maps/sceneApply.js",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/ui/imageryBoxNavara.js",
|
||||
"src/layers/installations/render/cesium/namedMarkers.js",
|
||||
"src/geo/",
|
||||
"src/time/clock.js",
|
||||
"src/globe/navara/overlay.js",
|
||||
@@ -3467,10 +3460,7 @@
|
||||
"src/celestialRing/render/cesium.js",
|
||||
"src/celestialRing/view.js",
|
||||
"src/layers/shared/render/navaraPolygons.js",
|
||||
"src/layers/installations/render/navara.js",
|
||||
"src/layers/awareness/render/navara.js",
|
||||
"src/layers/awareness/render/navaraPulses.js",
|
||||
"src/layers/infrastructure/render/navara.js",
|
||||
"src/layers/shared/render/navaraStems.js",
|
||||
"src/layers/shared/render/navaraSceneDepth.js",
|
||||
"src/layers/directions/render/navara.js",
|
||||
@@ -3488,9 +3478,6 @@
|
||||
"src/layers/shared/render/groundRing.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/shared/viewRectangle.js",
|
||||
"src/layers/installations/render/navaraGeometry.js",
|
||||
"src/layers/installations/render/navaraOutlineBatch.js",
|
||||
"src/layers/installations/render/navaraStencilBatch.js",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/shared/render/navaraMoverBank.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
@@ -3584,7 +3571,14 @@
|
||||
"src/layers/gnss/render/display.js",
|
||||
"src/layers/firms/render/cesiumHeat.js",
|
||||
"src/layers/firms/render/heatField.js",
|
||||
"src/layers/firms/render/display.js"
|
||||
"src/layers/firms/render/display.js",
|
||||
"src/layers/installations/render/style.js",
|
||||
"src/layers/awareness/render/cesiumPulses.js",
|
||||
"src/layers/awareness/render/pulseDisc.js",
|
||||
"src/layers/awareness/render/display.js",
|
||||
"src/layers/installations/render/namedMarkers.js",
|
||||
"src/layers/installations/render/display.js",
|
||||
"src/layers/infrastructure/render/display.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3763,7 +3757,6 @@
|
||||
"src/layers/awareness/policy.js",
|
||||
"src/layers/awareness/presentation.js",
|
||||
"src/layers/awareness/queries.js",
|
||||
"src/layers/awareness/render/cesium.js",
|
||||
"src/layers/awareness/rendering.js",
|
||||
"src/layers/awareness/state.js",
|
||||
"src/layers/awareness/subject.js",
|
||||
@@ -4230,13 +4223,8 @@
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/installations/render/cesium/lifecycle.js",
|
||||
"src/layers/installations/render/cesium.js",
|
||||
"src/layers/installations/render/cesium/rendering.js",
|
||||
"src/layers/installations/render/cesium/selection.js",
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/layers/installations/render/cesium/namedMarkers.js",
|
||||
"src/geo/",
|
||||
"src/time/clock.js",
|
||||
"src/globe/follow.js",
|
||||
@@ -4320,7 +4308,14 @@
|
||||
"src/layers/ghostMaps/render/display.js",
|
||||
"src/layers/firms/render/cesiumHeat.js",
|
||||
"src/layers/firms/render/heatField.js",
|
||||
"src/layers/firms/render/display.js"
|
||||
"src/layers/firms/render/display.js",
|
||||
"src/layers/infrastructure/render/display.js",
|
||||
"src/layers/installations/render/style.js",
|
||||
"src/layers/awareness/render/cesiumPulses.js",
|
||||
"src/layers/awareness/render/pulseDisc.js",
|
||||
"src/layers/awareness/render/display.js",
|
||||
"src/layers/installations/render/namedMarkers.js",
|
||||
"src/layers/installations/render/display.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createInstallationsLayer } from '../../layers/installations/index.js';
|
||||
import { createInstallationsCesiumRenderer } from '../../layers/installations/render/cesium.js';
|
||||
import { rendererFor } from './renderers.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationInstallations({ surface, source }) {
|
||||
const { groundFloor: ground, anchors } = surface;
|
||||
return createInstallationsLayer({
|
||||
source,
|
||||
overlayHost,
|
||||
rendererFactory: ({ engine, ...context }) =>
|
||||
engine?.kind === 'navara'
|
||||
? rendererFor('military-installations', engine, context)
|
||||
: createInstallationsCesiumRenderer(context),
|
||||
rendererFor('military-installations', engine, context),
|
||||
services: {
|
||||
credits,
|
||||
render,
|
||||
|
||||
@@ -46,6 +46,9 @@ import { createFirmsDisplay } from '../../layers/firms/render/display.js';
|
||||
import { createGhostMapsDisplay } from '../../layers/ghostMaps/render/display.js';
|
||||
import { createCyclonesDisplay } from '../../layers/cyclones/render/display.js';
|
||||
import { createCablesDisplay } from '../../layers/submarineCables/render/display.js';
|
||||
import { createInfrastructureDisplay } from '../../layers/infrastructure/render/display.js';
|
||||
import { createInstallationsDisplay } from '../../layers/installations/render/display.js';
|
||||
import { createAwarenessDisplay } from '../../layers/awareness/render/display.js';
|
||||
|
||||
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
||||
|
||||
@@ -119,6 +122,8 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||
's2-ghostmaps': createGhostMapsDisplay,
|
||||
'weather-cyclones': createCyclonesDisplay,
|
||||
'telegeography-submarine-cables': createCablesDisplay,
|
||||
'local-datacenters': createInfrastructureDisplay,
|
||||
'local-dams': createInfrastructureDisplay,
|
||||
'rocket-launches': createLaunchesDisplay,
|
||||
satellites: createSatellitesDisplay,
|
||||
flights: createFlightsDisplay,
|
||||
@@ -126,6 +131,8 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||
'local-adsb': createLocalAdsbDisplay,
|
||||
'ais-live-vessels': createVesselsDisplay,
|
||||
cctv: createCctvDisplay,
|
||||
'military-installations': createInstallationsDisplay,
|
||||
'military-awareness': createAwarenessDisplay,
|
||||
});
|
||||
|
||||
// The other application layers still draw themselves on Cesium.
|
||||
|
||||
@@ -74,6 +74,8 @@ test('the layers with extracted renderers', () => {
|
||||
's2-ghostmaps',
|
||||
'weather-cyclones',
|
||||
'telegeography-submarine-cables',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'rocket-launches',
|
||||
'satellites',
|
||||
'flights',
|
||||
@@ -81,6 +83,8 @@ test('the layers with extracted renderers', () => {
|
||||
'local-adsb',
|
||||
'ais-live-vessels',
|
||||
'cctv',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -104,6 +108,9 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
'drone-airspace': ['droneAirspace', 'createDroneAirspaceDisplay'],
|
||||
's2-ghostmaps': ['ghostMaps', 'createGhostMapsDisplay'],
|
||||
'weather-cyclones': ['cyclones', 'createCyclonesDisplay'],
|
||||
'local-datacenters': ['infrastructure', 'createInfrastructureDisplay'],
|
||||
'local-dams': ['infrastructure', 'createInfrastructureDisplay'],
|
||||
'military-installations': ['installations', 'createInstallationsDisplay'],
|
||||
'telegeography-submarine-cables': [
|
||||
'submarineCables',
|
||||
'createCablesDisplay',
|
||||
|
||||
+17
-27
@@ -182,40 +182,30 @@ export async function registerNavaraLayerRenderers() {
|
||||
'weather-cyclones': async () =>
|
||||
(await import('../layers/cyclones/render/display.js'))
|
||||
.createCyclonesDisplay,
|
||||
...Object.fromEntries(
|
||||
['local-datacenters', 'local-dams'].map((id) => [
|
||||
id,
|
||||
async () => {
|
||||
const { createInfrastructureNavaraRenderer } =
|
||||
await import('../layers/infrastructure/render/navara.js');
|
||||
return (engine, { config, services }) =>
|
||||
createInfrastructureNavaraRenderer(
|
||||
engine,
|
||||
engine.extensions.navara,
|
||||
config,
|
||||
services,
|
||||
);
|
||||
},
|
||||
]),
|
||||
),
|
||||
'local-datacenters': async () =>
|
||||
(await import('../layers/infrastructure/render/display.js'))
|
||||
.createInfrastructureDisplay,
|
||||
'local-dams': async () =>
|
||||
(await import('../layers/infrastructure/render/display.js'))
|
||||
.createInfrastructureDisplay,
|
||||
's2-ghostmaps': async () =>
|
||||
(await import('../layers/ghostMaps/render/display.js'))
|
||||
.createGhostMapsDisplay,
|
||||
// Global Context's display, with the Navara backend of its range pulses.
|
||||
'military-awareness': async () => {
|
||||
const { createAwarenessNavaraRenderer } =
|
||||
await import('../layers/awareness/render/navara.js');
|
||||
const [{ createAwarenessDisplay }, { createAwarenessPulseDisc }] =
|
||||
await Promise.all([
|
||||
import('../layers/awareness/render/display.js'),
|
||||
import('../layers/awareness/render/navaraPulses.js'),
|
||||
]);
|
||||
return (engine) =>
|
||||
createAwarenessNavaraRenderer(engine, engine.extensions.navara);
|
||||
},
|
||||
'military-installations': async () => {
|
||||
const { createInstallationsNavaraRenderer } =
|
||||
await import('../layers/installations/render/navara.js');
|
||||
return (engine, options) =>
|
||||
createInstallationsNavaraRenderer(engine, {
|
||||
...options,
|
||||
...engine.extensions.navara,
|
||||
createAwarenessDisplay(engine, {
|
||||
navaraPulses: createAwarenessPulseDisc,
|
||||
});
|
||||
},
|
||||
'military-installations': async () =>
|
||||
(await import('../layers/installations/render/display.js'))
|
||||
.createInstallationsDisplay,
|
||||
// The FIRMS display, with the Navara backend of its heatmap.
|
||||
'local-firms': async () => {
|
||||
const [{ createFirmsDisplay }, { createFirmsHeatDrape }] =
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
|
||||
import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js';
|
||||
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
|
||||
|
||||
/**
|
||||
* Create fresh datacenter and dam layers without starting or loading them.
|
||||
* @param {object} services Caller-owned context, overlay and render operations.
|
||||
* @param {object} services Caller-owned context, overlay and credit operations.
|
||||
* @param {(config: object, services: object) => object} [createLayer] One
|
||||
* layer from its descriptor; by default a session layer that draws through
|
||||
* the globe contract (src/layers/infrastructure).
|
||||
* @returns {object[]} Datacenters then dams, with stable standalone identities.
|
||||
*/
|
||||
export function createInfrastructureLayers(
|
||||
services,
|
||||
createLayer = createLocalGeoJsonLayer,
|
||||
createLayer = createInfrastructureSessionLayer,
|
||||
) {
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
|
||||
@@ -2,9 +2,8 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createInfrastructureLayers } from 'gods-eye-view/infrastructure';
|
||||
import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
|
||||
function services() {
|
||||
const records = new Map();
|
||||
@@ -13,7 +12,7 @@ function services() {
|
||||
return {
|
||||
records,
|
||||
credited,
|
||||
showOsmCredit: (viewer) => credited.push(viewer),
|
||||
showOsmCredit: (engine) => credited.push(engine),
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
registerEntityContext(entity, metadata) {
|
||||
records.set(metadata.id, { entity, ...metadata });
|
||||
@@ -95,7 +94,7 @@ test('dataset URLs still name the complete bundled sources', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('two viewers use their supplied contexts and dispose independently', async (t) => {
|
||||
test('two engines use their supplied contexts and dispose independently', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => ({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
@@ -118,57 +117,42 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
}));
|
||||
const hosts = [services(), services()];
|
||||
const instances = hosts.map((host) => {
|
||||
const sources = new Cesium.DataSourceCollection();
|
||||
let click;
|
||||
const layer = createLocalGeoJsonLayer(
|
||||
{
|
||||
id: 'local-dams',
|
||||
name: 'Dams',
|
||||
osmDerived: true,
|
||||
color: '#0088ff',
|
||||
url: '/dams.geojsonl',
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction(fn) {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
},
|
||||
host,
|
||||
);
|
||||
const viewer = {
|
||||
dataSources: sources,
|
||||
scene: {
|
||||
canvas: {},
|
||||
preRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
pick: () => ({ id: sources.get(0).entities.values[0] }),
|
||||
},
|
||||
camera: { moveEnd: new Cesium.Event(), flyTo() {} },
|
||||
};
|
||||
return { layer, viewer, click: () => click({ position: {} }) };
|
||||
const engine = createFakeEngine();
|
||||
const [, layer] = createInfrastructureLayers(host);
|
||||
layer.init(null, { engine });
|
||||
return { layer, engine };
|
||||
});
|
||||
t.after(() =>
|
||||
instances.forEach(({ layer, viewer }) => layer.destroy(viewer)),
|
||||
instances.forEach(({ layer, engine }) => {
|
||||
layer.destroy();
|
||||
engine.destroy();
|
||||
}),
|
||||
);
|
||||
await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer)));
|
||||
await Promise.all(instances.map(({ layer }) => layer.enable()));
|
||||
assert.equal(hosts[0].records.size, 1);
|
||||
assert.deepEqual(
|
||||
hosts.map((host) => host.credited),
|
||||
instances.map(({ viewer }) => [viewer]),
|
||||
instances.map(({ engine }) => [engine]),
|
||||
);
|
||||
assert.equal(hosts[1].records.size, 1);
|
||||
instances[0].click();
|
||||
// A click on the first engine's footprint selects only its own record.
|
||||
const { engine } = instances[0];
|
||||
engine.camera.flyTo = () => {};
|
||||
engine.fake.script('pick', {
|
||||
id: 'local-dams:same-id',
|
||||
owner: 'local-dams-footprints',
|
||||
instance: 0,
|
||||
});
|
||||
engine.fake.input('click', { position: { x: 1, y: 1 } });
|
||||
assert.ok(hosts[0].selection());
|
||||
assert.equal(hosts[1].selection(), undefined);
|
||||
instances[0].layer.destroy(instances[0].viewer);
|
||||
instances[0].layer.destroy();
|
||||
assert.equal(hosts[0].records.size, 0);
|
||||
assert.equal(hosts[1].records.size, 1);
|
||||
assert.equal(instances[1].viewer.dataSources.length, 1);
|
||||
assert.equal(instances[1].engine.ground.list().length, 2);
|
||||
});
|
||||
|
||||
test('consumer build includes only infrastructure code and resolves assets under a non-root base', async () => {
|
||||
test('consumer build includes only infrastructure code and engine-free geometry, and resolves assets under a non-root base', async () => {
|
||||
const { build } = await import('vite');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const result = await build({
|
||||
@@ -189,18 +173,38 @@ test('consumer build includes only infrastructure code and resolves assets under
|
||||
});
|
||||
const output = result.output;
|
||||
const entry = output.find((item) => item.type === 'chunk' && item.isEntry);
|
||||
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
|
||||
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
|
||||
'infrastructure.js',
|
||||
'infrastructureData.js',
|
||||
'infrastructureOverlayEntry.js',
|
||||
'localGeojsonCore.js',
|
||||
'localGeojsonLod.js',
|
||||
const sources = Object.keys(entry.modules)
|
||||
.filter((id) => id.endsWith('.js'))
|
||||
.map((id) => id.slice(id.lastIndexOf('/src/') + 1));
|
||||
assert.deepEqual(sources.sort(), [
|
||||
'src/data/infrastructure.js',
|
||||
'src/data/infrastructureOverlayEntry.js',
|
||||
'src/data/localGeojsonCore.js',
|
||||
'src/data/localGeojsonLod.js',
|
||||
...[
|
||||
'boundingSphere',
|
||||
'cartesian2',
|
||||
'cartesian3',
|
||||
'cartographic',
|
||||
'ellipsoid',
|
||||
'ellipsoidGeodesic',
|
||||
'julianDate',
|
||||
'math',
|
||||
'matrix4',
|
||||
'quaternion',
|
||||
'scaleToGeodeticSurface',
|
||||
'simon1994',
|
||||
].map((name) => `src/geo/${name}.js`),
|
||||
'src/layers/infrastructure/index.js',
|
||||
'src/layers/infrastructure/render/display.js',
|
||||
'src/layers/shared/color.js',
|
||||
'src/sources/infrastructureData.js',
|
||||
'src/sources/requestDeadline.js',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entry.imports,
|
||||
['cesium'],
|
||||
'the viewer supplies the same Cesium dependency',
|
||||
[],
|
||||
'the layers draw through the engine they are given',
|
||||
);
|
||||
for (const name of ['datacenters', 'dams']) {
|
||||
const asset = output.find(
|
||||
|
||||
@@ -115,16 +115,20 @@ test('every scene click handler consults ownership before it picks', () => {
|
||||
// appear here too.
|
||||
const guarded = [
|
||||
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
|
||||
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/infrastructure/render/display.js',
|
||||
'const picked = event.picked;',
|
||||
],
|
||||
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
|
||||
// ALPR and bikeshare take the engine's click with its pick.
|
||||
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'],
|
||||
['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'],
|
||||
// FIRMS takes the engine dispatcher's click with its pick.
|
||||
['src/layers/firms/selection.js', 'const picked = click.picked;'],
|
||||
// Mapped Installations take the engine's click with its pick.
|
||||
[
|
||||
'src/layers/installations/render/cesium/selection.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
'src/layers/installations/render/display.js',
|
||||
"const id = typeof picked?.id === 'string' ? picked.id : null;",
|
||||
],
|
||||
['src/layers/launches/render/display.js', '[ctx.picked, ...ctx.drilled]'],
|
||||
[
|
||||
@@ -170,7 +174,7 @@ test('ambient selection handlers never claim the pointer themselves', () => {
|
||||
// moment a user clicked anything. Only tools claim.
|
||||
for (const file of [
|
||||
'src/data/trackingClickGesture.js',
|
||||
'src/data/localGeojsonCore.js',
|
||||
'src/layers/infrastructure/render/display.js',
|
||||
'src/data/cctvGizmo.js',
|
||||
'src/layers/vessels/selection.js',
|
||||
'src/layers/satellites/interaction.js',
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam.
|
||||
// Focused tests for the local-infrastructure analyst-record mapper. The
|
||||
// layer's snapshot is tested in src/layers/infrastructure/session.test.mjs.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
mapAnalystRecord,
|
||||
} from './localGeojsonCore.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost,
|
||||
});
|
||||
import { mapAnalystRecord } from './localGeojsonCore.js';
|
||||
|
||||
const DC_RAW = {
|
||||
id: '1176042553',
|
||||
@@ -95,7 +84,7 @@ test('infra analyst record: empty record yields nulls, never NaN/undefined', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => {
|
||||
test('infra analyst record: output is JSON-safe (no engine types leak)', () => {
|
||||
const r = mapAnalystRecord(
|
||||
{
|
||||
...DAM_RAW,
|
||||
@@ -108,126 +97,3 @@ test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => {
|
||||
assert.equal('entity' in r, false);
|
||||
assert.equal('position' in r, false);
|
||||
});
|
||||
|
||||
class MockLayerEvent {
|
||||
constructor() {
|
||||
this.listeners = new Set();
|
||||
}
|
||||
|
||||
addEventListener(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
type: 'Feature',
|
||||
id: 'runtime-dam',
|
||||
properties: {
|
||||
name: 'Runtime Dam',
|
||||
output: '12 MW',
|
||||
tags: { associated_river: 'Test River', operator: 'Test Hydro' },
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-97.7, 30.2],
|
||||
[-97.69, 30.2],
|
||||
[-97.69, 30.21],
|
||||
[-97.7, 30.2],
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const viewer = {
|
||||
selectedEntity: undefined,
|
||||
dataSources: {
|
||||
add(dataSource) {
|
||||
return dataSource;
|
||||
},
|
||||
remove() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
|
||||
frustum: { fov: Math.PI / 3 },
|
||||
moveEnd: new MockLayerEvent(),
|
||||
flyTo() {},
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
preRender: new MockLayerEvent(),
|
||||
sampleHeightSupported: false,
|
||||
sampleHeight() {
|
||||
return undefined;
|
||||
},
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
pick() {
|
||||
return null;
|
||||
},
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const layer = createLocalGeoJsonLayer({
|
||||
id: 'local-dams',
|
||||
url: '/runtime-dam.geojsonl',
|
||||
name: 'Runtime Dams',
|
||||
color: '#0088ff',
|
||||
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
|
||||
projectToWindow: () => ({ x: 400, y: 300 }),
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords(),
|
||||
[],
|
||||
'unenabled layer has no analyst records',
|
||||
);
|
||||
await layer.enable(viewer);
|
||||
const rows = layer.getAnalystRecords();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].id, 'Runtime Dam');
|
||||
assert.equal(rows[0].name, 'Runtime Dam');
|
||||
assert.equal(rows[0].operator, 'Test Hydro');
|
||||
assert.equal(rows[0].river, 'Test River');
|
||||
assert.equal(rows[0].output, '12 MW');
|
||||
assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon));
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]);
|
||||
assert.equal(
|
||||
layer.getAnalystRecords(0).length,
|
||||
1,
|
||||
'non-finite/zero cap still returns at least one',
|
||||
);
|
||||
|
||||
layer.disable(viewer);
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords(),
|
||||
[],
|
||||
'disable keeps stems but analyst snapshot is empty',
|
||||
);
|
||||
} finally {
|
||||
layer.destroy(viewer);
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords(),
|
||||
[],
|
||||
'destroy releases analyst records',
|
||||
);
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
+90
-1348
File diff suppressed because it is too large.
Load diff
+9
-1061
File diff suppressed because it is too large.
Load diff
@@ -1,175 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import { createLocalGeoJsonLayer as createLayer } from './localGeojsonCore.js';
|
||||
import { getContextStore } from './contextStore.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost,
|
||||
});
|
||||
|
||||
const dataset = JSON.stringify({
|
||||
type: 'Feature', id: 'dam', properties: { name: 'Test dam' },
|
||||
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
|
||||
});
|
||||
const response = () => ({ ok: true, text: async () => dataset });
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
function harness(t) {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
t.after(() => {
|
||||
layer.destroy(viewer);
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
const sources = new Set();
|
||||
const listeners = new Set();
|
||||
const event = { addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn); } };
|
||||
let handlers = 0;
|
||||
const viewer = {
|
||||
dataSources: {
|
||||
async add(source) { sources.add(source); return source; },
|
||||
remove(source) { return sources.delete(source); },
|
||||
},
|
||||
scene: { canvas: {}, preRender: event, requestRender() {} },
|
||||
camera: { moveEnd: event },
|
||||
};
|
||||
const create = () => createLocalGeoJsonLayer({
|
||||
id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl',
|
||||
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
|
||||
screenSpaceEventHandlerFactory() {
|
||||
handlers++;
|
||||
return { setInputAction() {}, destroy() { handlers--; } };
|
||||
},
|
||||
});
|
||||
const layer = create();
|
||||
return { layer, create, viewer, sources, listeners, handlers: () => handlers };
|
||||
}
|
||||
|
||||
test('destroy removes context records and permits a fresh replacement', async t => {
|
||||
const env = harness(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => response());
|
||||
await env.layer.enable(env.viewer);
|
||||
const oldEntity = [...getContextStore().entities.values()][0].entity;
|
||||
assert.equal(getContextStore().entities.size, 1);
|
||||
env.layer.disable(env.viewer);
|
||||
assert.equal(getContextStore().entities.size, 0, 'disable releases its records with the source');
|
||||
assert.equal(env.sources.size, 0, 'disable removes the built source from the scene');
|
||||
env.layer.destroy(env.viewer);
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
assert.equal(env.sources.size, 0);
|
||||
assert.equal(env.listeners.size, 0);
|
||||
assert.equal(env.handlers(), 0);
|
||||
const replacement = env.create();
|
||||
await replacement.enable(env.viewer);
|
||||
assert.equal(getContextStore().entities.size, 1);
|
||||
assert.notEqual([...getContextStore().entities.values()][0].entity, oldEntity);
|
||||
replacement.destroy(env.viewer);
|
||||
await env.layer.enable(env.viewer);
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
});
|
||||
|
||||
for (const phase of ['fetch', 'text', 'parse', 'add']) {
|
||||
test(`destroy during ${phase} leaves no late objects or context records`, async t => {
|
||||
const env = harness(t);
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const pause = async value => { entered.resolve(); await release.promise; return value; };
|
||||
let signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
signal = options.signal;
|
||||
if (phase === 'fetch') return pause(response());
|
||||
if (phase === 'text') return { ok: true, text: () => pause(dataset) };
|
||||
return response();
|
||||
});
|
||||
if (phase === 'parse') {
|
||||
const load = Cesium.GeoJsonDataSource.load;
|
||||
t.mock.method(Cesium.GeoJsonDataSource, 'load', async (...args) => pause(await load(...args)));
|
||||
}
|
||||
if (phase === 'add') {
|
||||
t.mock.method(env.viewer.dataSources, 'add', async source => {
|
||||
await pause();
|
||||
env.sources.add(source);
|
||||
return source;
|
||||
});
|
||||
}
|
||||
const loading = env.layer.enable(env.viewer);
|
||||
await entered.promise;
|
||||
env.layer.destroy(env.viewer);
|
||||
assert.equal(signal.aborted, true);
|
||||
release.resolve();
|
||||
await loading;
|
||||
assert.equal(env.sources.size, 0);
|
||||
assert.equal(env.listeners.size, 0);
|
||||
assert.equal(env.handlers(), 0);
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
assert.deepEqual(env.layer.getStats(), { count: 0, lastUpdate: null, error: null });
|
||||
});
|
||||
}
|
||||
|
||||
test('concurrent enable and disable/re-enable share one pending dataset load', async t => {
|
||||
const env = harness(t);
|
||||
const release = deferred();
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
await release.promise;
|
||||
return response();
|
||||
});
|
||||
const first = env.layer.enable(env.viewer);
|
||||
env.layer.disable(env.viewer);
|
||||
const second = env.layer.enable(env.viewer);
|
||||
release.resolve();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
assert.equal(env.sources.size, 1);
|
||||
assert.equal(env.handlers(), 1);
|
||||
assert.equal(env.listeners.size, 2);
|
||||
assert.equal([...env.sources][0].show, true);
|
||||
});
|
||||
|
||||
test('disable during loading releases the completed source; re-enable rebuilds without refetching', async t => {
|
||||
const env = harness(t);
|
||||
const release = deferred();
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); });
|
||||
const loading = env.layer.enable(env.viewer);
|
||||
env.layer.disable(env.viewer);
|
||||
release.resolve();
|
||||
await loading;
|
||||
assert.equal(env.sources.size, 0, 'a build that finishes after disable() is released, not parked hidden');
|
||||
assert.equal(env.listeners.size, 0);
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
await env.layer.enable(env.viewer);
|
||||
assert.equal(env.sources.size, 1);
|
||||
assert.equal([...env.sources][0].show, true);
|
||||
assert.equal(fetchMock.mock.callCount(), 1, 'the parsed dataset is cached across the toggle');
|
||||
});
|
||||
|
||||
test('a toggle-off frees the entities and a toggle-on rebuilds them from the cached dataset', async t => {
|
||||
const env = harness(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => response());
|
||||
await env.layer.enable(env.viewer);
|
||||
const first = [...env.sources][0];
|
||||
assert.equal(first.entities.values.length, 1);
|
||||
assert.equal(getContextStore().entities.size, 1);
|
||||
env.layer.disable(env.viewer);
|
||||
assert.equal(env.sources.size, 0, 'no data source stays registered while the layer is off');
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
assert.equal(env.layer.getLodDiagnostics().total, 0);
|
||||
assert.equal(env.layer.getStats().count, 1, 'the row keeps reporting the dataset size');
|
||||
await env.layer.enable(env.viewer);
|
||||
const second = [...env.sources][0];
|
||||
assert.notEqual(second, first, 're-enable builds a fresh source');
|
||||
assert.equal(second.show, true);
|
||||
assert.equal(second.entities.values.length, 1);
|
||||
assert.equal(getContextStore().entities.size, 1);
|
||||
assert.equal(fetchMock.mock.callCount(), 1, 'no second fetch for a bundled dataset');
|
||||
assert.equal(env.layer.getLodDiagnostics().total, 1);
|
||||
});
|
||||
@@ -3,16 +3,16 @@
|
||||
* @module localGeojsonLod
|
||||
*
|
||||
* Pure selection policy for the bundled local-infrastructure layers
|
||||
* (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer`
|
||||
* dataset). Cesium-specific projection, occlusion, and stem geometry stay in
|
||||
* `localGeojsonCore.js`; this module turns the resulting in-view records into a
|
||||
* (`local-datacenters`, `local-dams`). Projection, occlusion, and stem
|
||||
* geometry stay in `src/layers/infrastructure/render/display.js`; this module
|
||||
* turns the resulting in-view records into a
|
||||
* bounded "active" set — the only records that layer should spend per-frame
|
||||
* stem-geometry and ground-sample work on.
|
||||
*
|
||||
* The screen-space label overlay is already decluttered
|
||||
* (LOCAL_OVERLAY_COHORT_LIMIT); this also bounds world-space stems and their
|
||||
* geometry/ground-sampling work. It does not reduce the number of materialized
|
||||
* entities. Submarine cables use their own renderer and are not changed here.
|
||||
* records. Submarine cables use their own renderer and are not changed here.
|
||||
* Performance must be measured against the current renderer, not inferred
|
||||
* from the number of hidden stems.
|
||||
*
|
||||
@@ -297,7 +297,7 @@ export function applyInfraEvictionGrace({
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Probe window for the motion fallback. `localGeojsonCore.js` marks its selection
|
||||
* Probe window for the motion fallback. The infrastructure display marks its selection
|
||||
* dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice
|
||||
* `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route
|
||||
* flight, or a continuous orbit never emits one. Without a fallback the
|
||||
@@ -377,7 +377,7 @@ export function infraLodMotionEpsilonM(
|
||||
* creeping camera probes on a fixed cadence instead of on every walk.
|
||||
*
|
||||
* Travel arrives SQUARED so the caller can hand over
|
||||
* `Cesium.Cartesian3.distanceSquared` and spend no sqrt on a per-walk probe,
|
||||
* `cartesian3.distanceSquared` and spend no sqrt on a per-walk probe,
|
||||
* the same trade `createCableReferenceSweepGate` makes.
|
||||
*
|
||||
* @param {object} [input]
|
||||
|
||||
@@ -47,6 +47,32 @@ import {
|
||||
} from '../layers/awareness/panel.js';
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { registerLayerRenderers } from '../app/layers/renderers.js';
|
||||
|
||||
// The layer's view of a stub viewer (render/display.js's interface), drawn
|
||||
// by nothing: these tests drive the layer's logic, not the ring. It reaches
|
||||
// the layer through the renderer registry, under an engine kind of its own.
|
||||
const VIEWER_ENGINE = 'awareness-viewer-test';
|
||||
registerLayerRenderers(VIEWER_ENGINE, {
|
||||
'military-awareness': ({ viewer }) => ({
|
||||
container: viewer.container,
|
||||
canvas: viewer.canvas,
|
||||
get camera() {
|
||||
return viewer.camera;
|
||||
},
|
||||
get trackedEntity() {
|
||||
return viewer.trackedEntity;
|
||||
},
|
||||
cameraHeight: () => viewer.camera?.positionCartographic?.height ?? NaN,
|
||||
onFrame: (run) => viewer.scene.preRender.addEventListener(run),
|
||||
onFrameEnd: () => () => {},
|
||||
frameContact: (center, radius, duration) =>
|
||||
viewer.camera.flyToBoundingSphere({ center, radius }, { duration }),
|
||||
createRim: () => ({ move() {}, setAlpha() {}, destroy() {} }),
|
||||
createPulses: () => null,
|
||||
}),
|
||||
});
|
||||
const viewerEngine = (viewer) => ({ kind: VIEWER_ENGINE, viewer });
|
||||
|
||||
const militaryAwarenessSource = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
@@ -220,7 +246,7 @@ function installAwarenessRuntime({
|
||||
...(subscribe ? { subscribe } : {}),
|
||||
});
|
||||
militaryAwarenessLayer.setParams({ passive: true });
|
||||
militaryAwarenessLayer.init(viewer);
|
||||
militaryAwarenessLayer.init(viewer, { engine: viewerEngine(viewer) });
|
||||
const panelViews = [];
|
||||
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
|
||||
panelViews.push(view),
|
||||
@@ -3747,7 +3773,7 @@ test('runtime listeners exist only while the awareness layer is enabled', () =>
|
||||
|
||||
try {
|
||||
militaryAwarenessLayer.setParams({ passive: true });
|
||||
militaryAwarenessLayer.init(viewer);
|
||||
militaryAwarenessLayer.init(viewer, { engine: viewerEngine(viewer) });
|
||||
assert.equal(
|
||||
[...listeners.values()].reduce((count, set) => count + set.size, 0),
|
||||
0,
|
||||
|
||||
@@ -24,7 +24,39 @@ import {
|
||||
installRenderGovernor,
|
||||
} from '../renderGovernor.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js';
|
||||
import { getContextStore, registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
|
||||
const LAYER_ID = 'military-installations';
|
||||
|
||||
/** A fake engine looking at `box` (degrees). */
|
||||
function engineOver(box) {
|
||||
const engine = createFakeEngine();
|
||||
engine.fake.script('viewRectangle', () => box && {
|
||||
south: Cesium.Math.toRadians(box.south),
|
||||
west: Cesium.Math.toRadians(box.west),
|
||||
north: Cesium.Math.toRadians(box.north),
|
||||
east: Cesium.Math.toRadians(box.east),
|
||||
});
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** The context handles of the drawn sites, in drawing order. */
|
||||
const drawnHandles = () =>
|
||||
[...getContextStore().entities.values()]
|
||||
.filter((record) => record.layerId === LAYER_ID)
|
||||
.map((record) => record.entity);
|
||||
|
||||
/** The drawn disc size of a site: 13 px selected, 9 px otherwise. */
|
||||
function discSize(engine, id) {
|
||||
const drawn = (setId) => {
|
||||
const set = engine.fake.moverSet(setId);
|
||||
return set.store.pickIds.slice(0, set.store.count).includes(id);
|
||||
};
|
||||
if (drawn(`${LAYER_ID}-selected`)) return 13;
|
||||
if (drawn(LAYER_ID)) return 9;
|
||||
return null;
|
||||
}
|
||||
|
||||
test('clicking a selected installation again or empty map clears it through refresh', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
@@ -37,7 +69,7 @@ test('clicking a selected installation again or empty map clears it through refr
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null, 'refresh must not resurrect selection');
|
||||
assert.equal(run.entities()[0].point.pixelSize.getValue(), 9);
|
||||
assert.equal(run.discSize(run.entities()[0].id), 9);
|
||||
}
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
@@ -55,7 +87,7 @@ test('background installation paints neither reopen Contacts nor resurrect a cle
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext(),null);
|
||||
assert.equal(selected(),1,'an external clear must survive the next paint');
|
||||
assert.equal(run.entities()[0].point.pixelSize.getValue(),9);
|
||||
assert.equal(run.discSize(run.entities()[0].id),9);
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
@@ -87,8 +119,8 @@ test('switching sites keeps the new selection through refresh and disable clears
|
||||
assert.equal(getSelectedEntityContext()?.id, 'osm:node:43');
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext()?.id, 'osm:node:43');
|
||||
assert.equal(run.entities().find(e => e.id === 'osm:node:42').point.pixelSize.getValue(), 9);
|
||||
assert.equal(run.entities().find(e => e.id === 'osm:node:43').point.pixelSize.getValue(), 13);
|
||||
assert.equal(run.discSize('osm:node:42'), 9);
|
||||
assert.equal(run.discSize('osm:node:43'), 13);
|
||||
militaryInstallationsLayer.disable();
|
||||
run.click('osm:node:42');
|
||||
assert.equal(getSelectedEntityContext(), null, 'disabled layer ignores clicks');
|
||||
@@ -134,10 +166,6 @@ test('real enabled installation entities carry no native label graphics', async
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const canvas = {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
globalThis.document = {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
@@ -163,49 +191,22 @@ test('real enabled installation entities carry no native label graphics', async
|
||||
}),
|
||||
};
|
||||
};
|
||||
const moveEndListeners = new Set();
|
||||
const dataSources = [];
|
||||
const viewer = {
|
||||
camera: {
|
||||
moveEnd: {
|
||||
addEventListener(listener) {
|
||||
moveEndListeners.add(listener);
|
||||
return () => moveEndListeners.delete(listener);
|
||||
},
|
||||
},
|
||||
computeViewRectangle() {
|
||||
return {
|
||||
south: Cesium.Math.toRadians(30),
|
||||
west: Cesium.Math.toRadians(-98),
|
||||
north: Cesium.Math.toRadians(31),
|
||||
east: Cesium.Math.toRadians(-97),
|
||||
};
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas,
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
pick() { return null; },
|
||||
},
|
||||
dataSources: {
|
||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
||||
remove(dataSource) {
|
||||
const index = dataSources.indexOf(dataSource);
|
||||
if (index >= 0) dataSources.splice(index, 1);
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
const engine = engineOver({ south: 30, west: -98, north: 31, east: -97 });
|
||||
|
||||
try {
|
||||
militaryInstallationsLayer.init(viewer);
|
||||
militaryInstallationsLayer.init(null, { engine });
|
||||
militaryInstallationsLayer.enable();
|
||||
await militaryInstallationsLayer.update();
|
||||
const entities = dataSources[0].entities.values;
|
||||
const entities = drawnHandles();
|
||||
assert.ok(entities.length > 0, 'runtime guard requires rendered installation records');
|
||||
assert.ok(entities.every((entity) => entity.label === undefined));
|
||||
assert.ok(
|
||||
engine.movers.list().every(({ kind }) => kind === 'point'),
|
||||
'the titles are the shared overlay, not label primitives',
|
||||
);
|
||||
} finally {
|
||||
militaryInstallationsLayer.destroy(viewer);
|
||||
militaryInstallationsLayer.destroy();
|
||||
engine.destroy();
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
@@ -305,68 +306,37 @@ async function runInstallationLoad({
|
||||
if (!legacyPayload) payload.saturated = exact ? exactSaturated : saturated;
|
||||
return { ok: true, status: 200, json: async () => payload };
|
||||
};
|
||||
const dataSources = [];
|
||||
const cameraFlights = [];
|
||||
let picked = null;
|
||||
let clickAction;
|
||||
const engine = engineOver(VIEWPORT);
|
||||
// Enough of a viewer for the real render governor, so one-shot render
|
||||
// requests are observable.
|
||||
const viewer = {
|
||||
camera: {
|
||||
moveEnd: { addEventListener() { return () => {}; } },
|
||||
flyToBoundingSphere(sphere, options) { cameraFlights.push({ sphere, options }); },
|
||||
computeViewRectangle() {
|
||||
return {
|
||||
south: Cesium.Math.toRadians(VIEWPORT.south),
|
||||
west: Cesium.Math.toRadians(VIEWPORT.west),
|
||||
north: Cesium.Math.toRadians(VIEWPORT.north),
|
||||
east: Cesium.Math.toRadians(VIEWPORT.east),
|
||||
};
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
pick() { return picked; },
|
||||
// Enough surface for the real render governor to drive this viewer, so
|
||||
// one-shot render requests are observable.
|
||||
requestRenderMode: false,
|
||||
maximumRenderTimeChange: 0,
|
||||
requestRender() {},
|
||||
},
|
||||
dataSources: {
|
||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
||||
remove(dataSource) {
|
||||
const index = dataSources.indexOf(dataSource);
|
||||
if (index >= 0) dataSources.splice(index, 1);
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const originalSetInputAction = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
|
||||
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (action, type, modifier) {
|
||||
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) clickAction = action;
|
||||
return originalSetInputAction.call(this, action, type, modifier);
|
||||
};
|
||||
try { militaryInstallationsLayer.init(viewer); }
|
||||
finally { Cesium.ScreenSpaceEventHandler.prototype.setInputAction = originalSetInputAction; }
|
||||
militaryInstallationsLayer.init(null, { engine });
|
||||
installRenderGovernor(viewer);
|
||||
militaryInstallationsLayer.enable();
|
||||
await militaryInstallationsLayer.update();
|
||||
|
||||
return {
|
||||
requests,
|
||||
cameraFlights,
|
||||
entities: () => dataSources[0]?.entities?.values || [],
|
||||
engine,
|
||||
entities: drawnHandles,
|
||||
discSize: (id) => discSize(engine, id),
|
||||
contextLabels: () => contextEvents,
|
||||
stats: () => militaryInstallationsLayer.getStats(),
|
||||
click(target) {
|
||||
const entity = typeof target === 'string' ? dataSources[0].entities.getById(target) : target;
|
||||
picked = entity ? { id: entity } : undefined;
|
||||
clickAction({ position: { x: 0, y: 0 } });
|
||||
engine.fake.script('pick', target ? { id: target } : undefined);
|
||||
engine.fake.input('click', { position: { x: 0, y: 0 } });
|
||||
engine.fake.unscript('pick');
|
||||
},
|
||||
renderRequests: () => getRenderGovernorDiagnostics().recentRequests.map((item) => item.reason),
|
||||
restore() {
|
||||
militaryInstallationsLayer.destroy(viewer);
|
||||
militaryInstallationsLayer.destroy();
|
||||
engine.destroy();
|
||||
_resetRenderGovernorForTest();
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
@@ -603,44 +573,18 @@ test('a floor that lands after the render deadline lifts the dots off the ellips
|
||||
}),
|
||||
};
|
||||
};
|
||||
const dataSources = [];
|
||||
const viewer = {
|
||||
camera: {
|
||||
moveEnd: { addEventListener() { return () => {}; } },
|
||||
computeViewRectangle() {
|
||||
return {
|
||||
south: Cesium.Math.toRadians(44),
|
||||
west: Cesium.Math.toRadians(-111),
|
||||
north: Cesium.Math.toRadians(45),
|
||||
east: Cesium.Math.toRadians(-110),
|
||||
};
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
pick() { return null; },
|
||||
},
|
||||
dataSources: {
|
||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
||||
remove(dataSource) {
|
||||
const index = dataSources.indexOf(dataSource);
|
||||
if (index >= 0) dataSources.splice(index, 1);
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
const engine = engineOver({ south: 44, west: -111, north: 45, east: -110 });
|
||||
|
||||
const heightOf = (entity) => Cesium.Cartographic.fromCartesian(
|
||||
entity.position.getValue(Cesium.JulianDate.now()),
|
||||
entity.gevDisplayPosition(),
|
||||
).height;
|
||||
|
||||
try {
|
||||
militaryInstallationsLayer.init(viewer);
|
||||
militaryInstallationsLayer.init(null, { engine });
|
||||
militaryInstallationsLayer.enable();
|
||||
await militaryInstallationsLayer.update();
|
||||
|
||||
const buried = dataSources[0].entities.values[0];
|
||||
const buried = drawnHandles()[0];
|
||||
assert.ok(buried, 'the cold-floor pass still renders the record');
|
||||
assert.ok(Math.abs(heightOf(buried)) < 1, 'a cold floor anchors at the ellipsoid, as before');
|
||||
|
||||
@@ -650,13 +594,14 @@ test('a floor that lands after the render deadline lifts the dots off the ellips
|
||||
}
|
||||
assert.equal(cachedGroundFloor(lat, lon), 2400, 'the late floor landed in the shared cache');
|
||||
|
||||
const lifted = dataSources[0].entities.values[0];
|
||||
const lifted = drawnHandles()[0];
|
||||
assert.ok(
|
||||
Math.abs(heightOf(lifted) - 2401.5) < 0.5,
|
||||
`re-render must lift the dot onto the resolved floor, got ${heightOf(lifted)}`,
|
||||
);
|
||||
} finally {
|
||||
militaryInstallationsLayer.destroy(viewer);
|
||||
militaryInstallationsLayer.destroy();
|
||||
engine.destroy();
|
||||
_resetFireAnchorsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
globalThis.fetch = originalFetch;
|
||||
@@ -681,28 +626,10 @@ test('reports bounded installation requests as loading and clears on settlement'
|
||||
}
|
||||
return installationsResponse;
|
||||
};
|
||||
const viewer = {
|
||||
camera: {
|
||||
moveEnd: { addEventListener() { return () => {}; } },
|
||||
computeViewRectangle() {
|
||||
return {
|
||||
south: Cesium.Math.toRadians(30),
|
||||
west: Cesium.Math.toRadians(-98),
|
||||
north: Cesium.Math.toRadians(31),
|
||||
east: Cesium.Math.toRadians(-97),
|
||||
};
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
pick() { return null; },
|
||||
},
|
||||
dataSources: { add(value) { return value; }, remove() { return true; } },
|
||||
};
|
||||
const engine = engineOver({ south: 30, west: -98, north: 31, east: -97 });
|
||||
|
||||
try {
|
||||
militaryInstallationsLayer.init(viewer);
|
||||
militaryInstallationsLayer.init(null, { engine });
|
||||
militaryInstallationsLayer.enable();
|
||||
const update = militaryInstallationsLayer.update();
|
||||
assert.equal(militaryInstallationsLayer.getStats().loading, true);
|
||||
@@ -718,7 +645,8 @@ test('reports bounded installation requests as loading and clears on settlement'
|
||||
await update;
|
||||
assert.equal(militaryInstallationsLayer.getStats().loading, false);
|
||||
} finally {
|
||||
militaryInstallationsLayer.destroy(viewer);
|
||||
militaryInstallationsLayer.destroy();
|
||||
engine.destroy();
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
@@ -745,28 +673,16 @@ test('zoom-out aborts an active installation request and returns non-loading gui
|
||||
}, { once: true });
|
||||
});
|
||||
};
|
||||
const viewer = {
|
||||
camera: {
|
||||
moveEnd: { addEventListener() { return () => {}; } },
|
||||
computeViewRectangle() {
|
||||
return globalView ? null : {
|
||||
south: Cesium.Math.toRadians(30),
|
||||
west: Cesium.Math.toRadians(-98),
|
||||
north: Cesium.Math.toRadians(31),
|
||||
east: Cesium.Math.toRadians(-97),
|
||||
};
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
pick() { return null; },
|
||||
},
|
||||
dataSources: { add(value) { return value; }, remove() { return true; } },
|
||||
};
|
||||
const engine = engineOver({ south: 30, west: -98, north: 31, east: -97 });
|
||||
engine.fake.script('viewRectangle', () => globalView ? null : {
|
||||
south: Cesium.Math.toRadians(30),
|
||||
west: Cesium.Math.toRadians(-98),
|
||||
north: Cesium.Math.toRadians(31),
|
||||
east: Cesium.Math.toRadians(-97),
|
||||
});
|
||||
|
||||
try {
|
||||
militaryInstallationsLayer.init(viewer);
|
||||
militaryInstallationsLayer.init(null, { engine });
|
||||
militaryInstallationsLayer.enable();
|
||||
const pending = militaryInstallationsLayer.update();
|
||||
assert.equal(militaryInstallationsLayer.getStats().loading, true);
|
||||
@@ -779,7 +695,8 @@ test('zoom-out aborts an active installation request and returns non-loading gui
|
||||
assert.equal(militaryInstallationsLayer.getStats().error, null, 'guidance is not a fault');
|
||||
assert.match(militaryInstallationsLayer.getStats().statusMessage, /zoom in/i);
|
||||
} finally {
|
||||
militaryInstallationsLayer.destroy(viewer);
|
||||
militaryInstallationsLayer.destroy();
|
||||
engine.destroy();
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
@@ -794,7 +711,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui
|
||||
// sat healthy — observed in the field as a layer stuck reporting unavailable
|
||||
// while its own endpoint served hundreds of features. The backoff progression
|
||||
// is a pure exported helper so it pins without booting the layer (the full
|
||||
// layer needs a Cesium viewer and interaction handlers); the wiring is pinned
|
||||
// layer needs a globe engine and interaction handlers); the wiring is pinned
|
||||
// by source probes against the shipped file, the same technique the HUD datum
|
||||
// tests use where a full boot is impractical.
|
||||
import { installationRetryDelayMs } from './militaryInstallations.js';
|
||||
|
||||
@@ -39,14 +39,18 @@ function assertCredit(v) {
|
||||
assert.ok(v.credits.some((credit) => credit.showOnScreen && credit.html.includes('>© OpenStreetMap<')));
|
||||
}
|
||||
for (const index of [0, 1]) test(`${index ? 'dams' : 'datacenters'} introduce the shared OSM credit when data displays`, async (t) => {
|
||||
const v = viewer(t);
|
||||
const before = globalThis.window;
|
||||
globalThis.window = { dispatchEvent: noop };
|
||||
t.after(() => { if (before === undefined) delete globalThis.window; else globalThis.window = before; });
|
||||
const engine = createFakeEngine();
|
||||
t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => JSON.stringify({ type: 'Feature', id: 'test', properties: { name: 'Site' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] } }) }));
|
||||
const layer = createInfrastructureLayers({ overlayHost, registerEntityContext: noop, selectEntityContext: noop, clearSelectedEntityContextForLayer: noop, removeEntityContextsForLayer: noop, governorRequestRender: noop, showOsmCredit, hideOsmCredit })[index];
|
||||
await layer.enable(v);
|
||||
const layer = createInfrastructureLayers({ overlayHost, registerEntityContext: noop, selectEntityContext: noop, clearSelectedEntityContextForLayer: noop, removeEntityContextsForLayer: noop, showOsmCredit, hideOsmCredit })[index];
|
||||
layer.init(null, { engine });
|
||||
await layer.enable();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assertCredit(v);
|
||||
layer.destroy(v);
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
assert.ok(engine.fake.credits.some((credit) => credit.onScreen && credit.html.includes('>© OpenStreetMap<')));
|
||||
layer.destroy();
|
||||
assert.equal(engine.fake.credits.filter((credit) => credit.onScreen).length, 0);
|
||||
});
|
||||
|
||||
test('Nepal locator introduces the shared OSM credit', async (t) => {
|
||||
|
||||
@@ -284,11 +284,11 @@ test('tracking layers write gevLabelModel and expose only their cached display p
|
||||
assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`);
|
||||
}
|
||||
const installations = await readFile(
|
||||
new URL('../layers/installations/render/cesium/rendering.js', import.meta.url),
|
||||
new URL('../layers/installations/render/display.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(installations.includes('entity.gevLabelModel ='));
|
||||
assert.ok(installations.includes('entity.gevDisplayPosition = () => displayPosition'));
|
||||
assert.ok(installations.includes(' gevLabelModel: {'));
|
||||
assert.ok(installations.includes('handle.gevDisplayPosition = () => handle.position'));
|
||||
assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
|
||||
assert.match(sources['militaryFlights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
|
||||
assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createAwarenessCesiumRenderer } from './render/cesium.js';
|
||||
import { cameraPoseSignature } from '../../data/iconOrientation.js';
|
||||
import { AWARENESS_RANGE_PULSE } from './policy.js';
|
||||
|
||||
@@ -281,12 +280,11 @@ export function createLifecycle({
|
||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
||||
},
|
||||
|
||||
attachRenderer(viewer, { engine = null } = {}) {
|
||||
attachRenderer(_scene, { engine = null } = {}) {
|
||||
detachRuntimeListeners();
|
||||
layerState.display?.destroy?.();
|
||||
layerState.display = viewer
|
||||
? createAwarenessCesiumRenderer(viewer)
|
||||
: (services.renderer?.(engine) ?? null);
|
||||
// The engine's display from the registry (render/display.js).
|
||||
layerState.display = services.renderer?.(engine) ?? null;
|
||||
if (layerState.enabled) {
|
||||
attachRuntimeListeners();
|
||||
if (layerState.subject)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// src/layers/awareness/rangeRing.test.mjs — the 250 km Global Context range
|
||||
// ring is a haloed polyline that stays legible on light and dark ground,
|
||||
// sits where the old ellipse outline did, and follows the subject.
|
||||
// ring is a core line over a halo line that stays legible on light and dark
|
||||
// ground, sits where the old ellipse outline did, and follows the subject.
|
||||
// Dim sonar pulses grow slowly from the subject to the ring, each with an
|
||||
// inward-fading gradient behind it, drawn on one unpickable disc; the ring
|
||||
// dims and flashes as each one arrives and the overlay's echo field follows
|
||||
// the subject. The pulses run for a bounded time after a selection or camera
|
||||
// move, then rest with the ring steady. Under reduced motion the ring simply
|
||||
// stays on screen.
|
||||
// stays on screen. The display runs on the Cesium engine over a stub scene
|
||||
// (src/testSupport/cesiumMoversEngine.mjs): its ring lines and the Cesium
|
||||
// pulse disc are real Cesium primitives that draw nothing.
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -25,14 +27,19 @@ import {
|
||||
awarenessPulseState,
|
||||
awarenessRangeRingPositions,
|
||||
awarenessRingFlash,
|
||||
awarenessRingGeometryOptions,
|
||||
createRendering,
|
||||
wakeAwarenessPulses,
|
||||
} from './rendering.js';
|
||||
import { getAwarenessEchoField } from '../../awarenessEcho.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createAwarenessCesiumRenderer } from './render/cesium.js';
|
||||
import { createState } from './state.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../../testSupport/cesiumMoversEngine.mjs';
|
||||
import {
|
||||
createAwarenessDisplay,
|
||||
RING_HALO_ID,
|
||||
RING_ID,
|
||||
} from './render/display.js';
|
||||
|
||||
/** WCAG relative luminance of a Cesium colour, ignoring alpha. */
|
||||
function luminance(color) {
|
||||
@@ -46,12 +53,15 @@ function luminance(color) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendering part over a bare entity collection, counting render asks and
|
||||
* by reason. `reducedMotion` stubs the media query for the length of the
|
||||
* The rendering part over the display on the Cesium engine (or `engine`),
|
||||
* counting render asks by reason. `reducedMotion` stubs the media query for the length of the
|
||||
* test; `motion.set(on)` flips it later and tells its listeners, as the
|
||||
* browser does.
|
||||
*/
|
||||
function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) {
|
||||
function ringRendering(
|
||||
t,
|
||||
{ reducedMotion = false, legacyQuery = false, engine: given = null } = {},
|
||||
) {
|
||||
const previous = globalThis.matchMedia;
|
||||
const listeners = new Set();
|
||||
// `legacyQuery` is a MediaQueryList as Safari before 14 shipped it, with
|
||||
@@ -94,13 +104,11 @@ function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) {
|
||||
listeners: () => listeners.size,
|
||||
};
|
||||
const state = createState({ services: {} });
|
||||
const entities = new Cesium.EntityCollection();
|
||||
const scene = {
|
||||
primitives: new Cesium.PrimitiveCollection(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
};
|
||||
state.display = createAwarenessCesiumRenderer({ entities, scene });
|
||||
const cesium = given ? null : createCesiumMoversEngine();
|
||||
const engine = given ?? cesium.engine;
|
||||
const viewer = cesium?.viewer ?? null;
|
||||
const scene = viewer?.scene ?? null;
|
||||
state.display = createAwarenessDisplay(engine);
|
||||
const reasons = [];
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
@@ -115,11 +123,18 @@ function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) {
|
||||
parts: {},
|
||||
});
|
||||
// A shown ring keeps a pulse timer; never leave one running past a test.
|
||||
t.after(() => rendering.clearVisual());
|
||||
t.after(() => {
|
||||
rendering.clearVisual();
|
||||
state.display?.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
return {
|
||||
state,
|
||||
entities,
|
||||
engine,
|
||||
viewer,
|
||||
entities: viewer?.entities,
|
||||
scene,
|
||||
primitive: cesium?.primitive,
|
||||
rendering,
|
||||
motion,
|
||||
reasons,
|
||||
@@ -144,13 +159,17 @@ function frameLoop(t, ring, { date = false } = {}) {
|
||||
now: clock,
|
||||
});
|
||||
let drawn = 0;
|
||||
let lastFrame = clock;
|
||||
const draw = () => {
|
||||
drawn = ring.reasons.length;
|
||||
lastFrame = clock;
|
||||
ring.scene.preRender.raiseEvent(ring.scene);
|
||||
ring.scene.postRender.raiseEvent(ring.scene);
|
||||
};
|
||||
return {
|
||||
now: () => clock,
|
||||
/** When the last frame was drawn. */
|
||||
lastFrame: () => lastFrame,
|
||||
draw,
|
||||
advance(ms) {
|
||||
for (let step = 0; step < ms; step++) {
|
||||
@@ -162,16 +181,29 @@ function frameLoop(t, ring, { date = false } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The ring and the pulse disc among scene primitives. */
|
||||
function pulsePrimitives(scene) {
|
||||
/**
|
||||
* The ring's core and halo lines and the pulse disc among the scene's
|
||||
* primitives; `drawn` counts all three.
|
||||
*/
|
||||
function pulsePrimitives(ring) {
|
||||
const { scene } = ring;
|
||||
const all = Array.from({ length: scene.primitives.length }, (_, i) =>
|
||||
scene.primitives.get(i),
|
||||
);
|
||||
const ring = all.find(
|
||||
(p) => p.appearance instanceof Cesium.PolylineMaterialAppearance,
|
||||
const discs = all.filter(
|
||||
(p) =>
|
||||
p instanceof Cesium.Primitive &&
|
||||
p.appearance instanceof Cesium.MaterialAppearance,
|
||||
);
|
||||
const discs = all.filter((p) => p instanceof Cesium.Primitive && p !== ring);
|
||||
return { ring, disc: discs[0], count: discs.length };
|
||||
const core = ring.primitive(RING_ID);
|
||||
const halo = ring.primitive(RING_HALO_ID);
|
||||
return {
|
||||
ring: core,
|
||||
halo,
|
||||
disc: discs[0],
|
||||
count: discs.length,
|
||||
drawn: [core, halo, ...discs].filter(Boolean).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a disc built around `built` sits after its model matrix. */
|
||||
@@ -183,10 +215,25 @@ function discCenter(disc, built) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The ring's material uniforms as the next drawn frame sets them. */
|
||||
function ringColors(scene) {
|
||||
scene.preRender.raiseEvent(scene);
|
||||
return pulsePrimitives(scene).ring.appearance.material.uniforms;
|
||||
/**
|
||||
* The ring's colours, core and halo, as the next drawn frame sets them
|
||||
* (`{ frame: false }`: as the last one did).
|
||||
*/
|
||||
function ringColors(ring, { frame = true } = {}) {
|
||||
if (frame) ring.scene.preRender.raiseEvent(ring.scene);
|
||||
const { ring: core, halo } = pulsePrimitives(ring);
|
||||
return {
|
||||
color: core.appearance.material.uniforms.color,
|
||||
outlineColor: halo.appearance.material.uniforms.color,
|
||||
};
|
||||
}
|
||||
|
||||
/** Puts the Cesium camera 800 km over (lon, 20°), looking down at a slant. */
|
||||
function setCamera(ring, lon) {
|
||||
ring.viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(lon, 20, 800_000),
|
||||
orientation: { heading: 0, pitch: -1, roll: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const flight = (lon) => ({
|
||||
@@ -288,79 +335,109 @@ test('the ring core and halo contrast on light and dark ground', () => {
|
||||
assert.ok(width - 2 * outlineWidth >= 2);
|
||||
});
|
||||
|
||||
test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { entities, scene, state, rendering } = ring;
|
||||
frameLoop(t, ring); // freeze the clock: the pulses have not faded in yet
|
||||
rendering.renderVisual(flight(3));
|
||||
// A polyline primitive, never an entity or an ellipse outline.
|
||||
assert.equal(entities.values.length, 0);
|
||||
const ringPrimitive = pulsePrimitives(scene).ring;
|
||||
assert.ok(ringPrimitive);
|
||||
const geometry = ringPrimitive.geometryInstances.geometry;
|
||||
assert.ok(geometry instanceof Cesium.PolylineGeometry);
|
||||
// 4 px wide, straight chords between surface-following positions, as the
|
||||
// entity ring was drawn.
|
||||
const options = awarenessRingGeometryOptions(state.visual.ringPositions);
|
||||
assert.equal(options.width, 4);
|
||||
assert.equal(options.width, AWARENESS_RANGE_RING_STYLE.width);
|
||||
assert.equal(options.arcType, Cesium.ArcType.NONE);
|
||||
assert.equal(options.positions, state.visual.ringPositions);
|
||||
assert.equal(
|
||||
options.vertexFormat,
|
||||
Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
|
||||
);
|
||||
const { material } = ringPrimitive.appearance;
|
||||
assert.equal(material.type, 'PolylineOutline');
|
||||
// The ring's hue is fixed; its opacity flashes with arriving pulses, and
|
||||
// at the moment it appears, before the pulses fade in, it is at full.
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
const { color, outlineWidth } = ringColors(scene);
|
||||
assert.equal(color.red, core.red);
|
||||
assert.equal(color.blue, core.blue);
|
||||
assert.ok(Math.abs(color.alpha - core.alpha) < 1e-9, `${color.alpha}`);
|
||||
assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth);
|
||||
const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
for (const distance of surfaceDistances(ground, state.visual.ringPositions))
|
||||
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
|
||||
test('renderVisual draws the ring as a core line over a halo line, not an ellipse outline', (t) => {
|
||||
for (const onCesium of [true, false]) {
|
||||
const engine = onCesium ? null : createFakeEngine();
|
||||
const ring = ringRendering(t, { engine });
|
||||
const { state, rendering } = ring;
|
||||
// Freeze the clock: the pulses have not faded in yet.
|
||||
if (onCesium) frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
const halo = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.outlineColor,
|
||||
);
|
||||
if (onCesium) {
|
||||
// Line primitives, never an entity or an ellipse outline.
|
||||
assert.equal(ring.entities.values.length, 0);
|
||||
const { ring: coreLine, halo: haloLine } = pulsePrimitives(ring);
|
||||
assert.ok(
|
||||
coreLine.geometryInstances.geometry instanceof Cesium.PolylineGeometry,
|
||||
);
|
||||
assert.ok(
|
||||
haloLine.geometryInstances.geometry instanceof Cesium.PolylineGeometry,
|
||||
);
|
||||
// The ring's hue is fixed; its opacity flashes with arriving pulses,
|
||||
// and at the moment it appears, before the pulses fade in, it is at
|
||||
// full.
|
||||
const { color, outlineColor } = ringColors(ring);
|
||||
assert.equal(color.red, core.red);
|
||||
assert.equal(color.blue, core.blue);
|
||||
assert.ok(Math.abs(color.alpha - core.alpha) < 1e-9, `${color.alpha}`);
|
||||
assert.ok(Math.abs(outlineColor.alpha - halo.alpha) < 1e-9);
|
||||
continue;
|
||||
}
|
||||
// 4 px wide with a 1 px halo, as Cesium's outline material drew the ring
|
||||
// (its core is the width less the outline), straight chords between
|
||||
// surface-following positions.
|
||||
const coreLine = engine.fake.line(RING_ID);
|
||||
const haloLine = engine.fake.line(RING_HALO_ID);
|
||||
assert.equal(haloLine.options.widthPx, AWARENESS_RANGE_RING_STYLE.width);
|
||||
assert.equal(
|
||||
coreLine.options.widthPx,
|
||||
AWARENESS_RANGE_RING_STYLE.width -
|
||||
AWARENESS_RANGE_RING_STYLE.outlineWidth,
|
||||
);
|
||||
assert.equal(coreLine.options.arc, 'none');
|
||||
assert.ok(
|
||||
coreLine.options.order > haloLine.options.order,
|
||||
'the core over the halo',
|
||||
);
|
||||
assert.equal(coreLine.options.pickId, undefined, 'never a click target');
|
||||
assert.ok(Math.abs(coreLine.color[3] - core.alpha) < 1e-9);
|
||||
assert.ok(Math.abs(haloLine.color[3] - halo.alpha) < 1e-9);
|
||||
const flat = coreLine.positions;
|
||||
const positions = Array.from(
|
||||
{ length: flat.length / 3 },
|
||||
(_, i) =>
|
||||
new Cesium.Cartesian3(flat[i * 3], flat[i * 3 + 1], flat[i * 3 + 2]),
|
||||
);
|
||||
assert.equal(positions.length, AWARENESS_RANGE_RING_STYLE.segments + 1);
|
||||
assert.deepEqual(haloLine.positions, flat);
|
||||
for (const distance of surfaceDistances(ground, positions))
|
||||
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
|
||||
rendering.clearVisual();
|
||||
assert.equal(engine.fake.line(RING_ID), undefined);
|
||||
assert.ok(state);
|
||||
}
|
||||
});
|
||||
|
||||
test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => {
|
||||
const { scene, state, rendering, renders } = ringRendering(t);
|
||||
const ring = ringRendering(t);
|
||||
const { state, rendering, renders } = ring;
|
||||
const subject = flight;
|
||||
rendering.renderVisual(subject(3));
|
||||
const ringBefore = pulsePrimitives(scene).ring;
|
||||
const first = state.visual.ringPositions;
|
||||
const discBefore = pulsePrimitives(scene).disc;
|
||||
const ringBefore = pulsePrimitives(ring).ring;
|
||||
const discBefore = pulsePrimitives(ring).disc;
|
||||
const rendersAfterAdd = renders();
|
||||
|
||||
// ~100 m east: under the threshold, nothing is rebuilt or rendered.
|
||||
rendering.renderVisual(subject(3.001));
|
||||
assert.equal(pulsePrimitives(scene).ring, ringBefore);
|
||||
assert.equal(state.visual.ringPositions, first);
|
||||
assert.equal(pulsePrimitives(ring).ring, ringBefore);
|
||||
assert.equal(renders(), rendersAfterAdd);
|
||||
|
||||
// ~1 km east: the ring is rebuilt around the new centre, keeping its
|
||||
// material, so a flash carries on.
|
||||
// ~1 km east: the ring is rebuilt around the new centre.
|
||||
rendering.renderVisual(subject(3.01));
|
||||
const ringAfter = pulsePrimitives(scene).ring;
|
||||
const ringAfter = pulsePrimitives(ring).ring;
|
||||
assert.notEqual(ringAfter, ringBefore);
|
||||
assert.ok(ringBefore.isDestroyed());
|
||||
assert.equal(ringAfter.appearance.material, ringBefore.appearance.material);
|
||||
assert.equal(ringAfter.allowPicking, false);
|
||||
const moved = state.visual.ringPositions;
|
||||
assert.notEqual(moved, first);
|
||||
for (const distance of surfaceDistances(
|
||||
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
|
||||
moved,
|
||||
))
|
||||
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
|
||||
assert.equal(pulsePrimitives(ring).halo.allowPicking, false);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
state.visual.center,
|
||||
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
|
||||
0,
|
||||
1e-3,
|
||||
),
|
||||
);
|
||||
assert.equal(renders(), rendersAfterAdd + 1);
|
||||
// The pulses are drawn on a disc that follows to the NEW centre by its
|
||||
// model matrix, so they stay concentric with the ring without a rebuild.
|
||||
assert.equal(pulsePrimitives(scene).disc, discBefore);
|
||||
assert.equal(pulsePrimitives(ring).disc, discBefore);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(
|
||||
discCenter(discBefore, Cesium.Cartesian3.fromDegrees(3, 27, 0)),
|
||||
@@ -369,7 +446,7 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift
|
||||
);
|
||||
|
||||
rendering.clearVisual();
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
assert.equal(pulsePrimitives(ring).drawn, 0);
|
||||
});
|
||||
|
||||
test('a pulse grows slowly from the subject and arrives dim at the ring', () => {
|
||||
@@ -441,7 +518,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con
|
||||
assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`);
|
||||
const frames = pulseFrames();
|
||||
rendering.clearVisual();
|
||||
assert.equal(ring.scene.primitives.length, 0);
|
||||
assert.equal(pulsePrimitives(ring).drawn, 0);
|
||||
assert.equal(ring.scene.postRender.numberOfListeners, 0);
|
||||
loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10);
|
||||
assert.equal(pulseFrames(), frames, 'no frames once the ring is gone');
|
||||
@@ -449,7 +526,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con
|
||||
|
||||
test('a parked view stops asking for frames once the pulses rest', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { scene, rendering, pulseFrames } = ring;
|
||||
const { rendering, pulseFrames } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE;
|
||||
// Long enough for three ring flashes, then quiet, and short: a burst is
|
||||
@@ -472,8 +549,8 @@ test('a parked view stops asking for frames once the pulses rest', (t) => {
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9);
|
||||
assert.equal(pulsePrimitives(scene).disc.show, false);
|
||||
assert.ok(Math.abs(ringColors(ring).color.alpha - core.alpha) < 1e-9);
|
||||
assert.equal(pulsePrimitives(ring).disc.show, false);
|
||||
assert.equal(getAwarenessEchoField().level(loop.now()), 0);
|
||||
});
|
||||
|
||||
@@ -508,15 +585,9 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera
|
||||
const loop = frameLoop(t, ring, { date: true });
|
||||
const { activeMs, inputWakeMs } = AWARENESS_RANGE_PULSE;
|
||||
state.enabled = true;
|
||||
state.display.viewer.canvas = new EventTarget();
|
||||
const touch = () =>
|
||||
state.display.viewer.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
state.display.viewer.camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
|
||||
heading: 0,
|
||||
pitch: -1,
|
||||
roll: 0,
|
||||
};
|
||||
state.display.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
setCamera(ring, 3);
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
@@ -542,11 +613,7 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera
|
||||
// The user drags the camera 50 km: the next frame wakes them for another
|
||||
// run.
|
||||
touch();
|
||||
state.display.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
3.5,
|
||||
20,
|
||||
800_000,
|
||||
);
|
||||
setCamera(ring, 3.5);
|
||||
loop.draw();
|
||||
loop.advance(activeMs + 500);
|
||||
assert.ok(
|
||||
@@ -559,11 +626,7 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera
|
||||
// tracking follow, the cockpit view, an orbit or a fly-to all move it on
|
||||
// every frame.
|
||||
const follow = (lon) => {
|
||||
state.display.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
20,
|
||||
800_000,
|
||||
);
|
||||
setCamera(ring, lon);
|
||||
loop.draw();
|
||||
loop.advance(500);
|
||||
};
|
||||
@@ -573,7 +636,7 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera
|
||||
// without a button pressed is not handling the camera.
|
||||
touch();
|
||||
loop.advance(inputWakeMs + 100);
|
||||
state.display.viewer.canvas.dispatchEvent(
|
||||
state.display.canvas.dispatchEvent(
|
||||
Object.assign(new Event('pointermove'), { buttons: 0 }),
|
||||
);
|
||||
follow(5.7);
|
||||
@@ -588,8 +651,7 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera
|
||||
loop.advance(activeMs);
|
||||
return pulseFrames() > before;
|
||||
};
|
||||
const onCanvas = (event) => () =>
|
||||
state.display.viewer.canvas.dispatchEvent(event);
|
||||
const onCanvas = (event) => () => state.display.canvas.dispatchEvent(event);
|
||||
assert.equal(panWith(onCanvas(new Event('wheel')), 5.8), true, 'wheel');
|
||||
assert.equal(panWith(onCanvas(new Event('touchstart')), 5.9), true, 'touch');
|
||||
assert.equal(
|
||||
@@ -620,16 +682,11 @@ function inputLifecycle(t, ring) {
|
||||
const { state, rendering } = ring;
|
||||
const loop = frameLoop(t, ring, { date: true });
|
||||
state.enabled = true;
|
||||
state.display.viewer.canvas = new EventTarget();
|
||||
state.display.viewer.scene.screenSpaceCameraController = {
|
||||
enableInputs: true,
|
||||
};
|
||||
state.display.viewer.camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
|
||||
heading: 0,
|
||||
pitch: -1,
|
||||
roll: 0,
|
||||
};
|
||||
// The contract cannot say whether the camera controller takes input, so
|
||||
// the display does not either; the lifecycle reads it when a display does.
|
||||
const controller = { enableInputs: true };
|
||||
state.display.cameraInputsEnabled = () => controller.enableInputs;
|
||||
setCamera(ring, 3);
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
@@ -651,18 +708,12 @@ function inputLifecycle(t, ring) {
|
||||
return {
|
||||
loop,
|
||||
lifecycle,
|
||||
controller: state.display.viewer.scene.screenSpaceCameraController,
|
||||
controller,
|
||||
dispatch: (type, init = {}) =>
|
||||
state.display.viewer.canvas.dispatchEvent(
|
||||
Object.assign(new Event(type), init),
|
||||
),
|
||||
state.display.canvas.dispatchEvent(Object.assign(new Event(type), init)),
|
||||
pan(stepDeg = 0.1) {
|
||||
lon += stepDeg;
|
||||
state.display.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
20,
|
||||
800_000,
|
||||
);
|
||||
setCamera(ring, lon);
|
||||
loop.draw();
|
||||
},
|
||||
/** Whether the pulses drew frames during the next `ms`. */
|
||||
@@ -834,12 +885,7 @@ test('disabling the layer tears the pulses down completely', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { state, scene, rendering, motion, pulseFrames } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
state.display.viewer.camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
|
||||
heading: 0,
|
||||
pitch: -1,
|
||||
roll: 0,
|
||||
};
|
||||
setCamera(ring, 3);
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
@@ -869,9 +915,9 @@ test('disabling the layer tears the pulses down completely', (t) => {
|
||||
rendering.renderVisual(state.subject);
|
||||
loop.advance(1000);
|
||||
assert.ok(pulseFrames() > 0);
|
||||
assert.equal(scene.primitives.length, 2);
|
||||
assert.equal(pulsePrimitives(ring).drawn, 3);
|
||||
lifecycle.methods.disable();
|
||||
assert.equal(scene.primitives.length, 0, 'ring and disc removed');
|
||||
assert.equal(pulsePrimitives(ring).drawn, 0, 'ring and disc removed');
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postRender.numberOfListeners, 0);
|
||||
assert.equal(motion.listeners(), 0, 'no media-query listener');
|
||||
@@ -924,22 +970,17 @@ test('the pulse level fades in, holds, fades out and keeps its level when woken'
|
||||
assert.equal(awarenessPulseLevel(at + activeMs, wake), 0);
|
||||
});
|
||||
|
||||
test('a scene without primitives or preRender gets the static ring only', (t) => {
|
||||
const { state, entities, rendering, pulseFrames } = ringRendering(t);
|
||||
for (const scene of [
|
||||
{},
|
||||
{ primitives: { add() {} } },
|
||||
{ primitives: { add() {} }, preRender: new Cesium.Event() },
|
||||
undefined,
|
||||
]) {
|
||||
state.display = createAwarenessCesiumRenderer({ entities, scene });
|
||||
rendering.clearVisual();
|
||||
rendering.renderVisual(flight(3));
|
||||
// No usable primitive collection: the ring falls back to an entity.
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values[0].polyline.positions.isConstant, true);
|
||||
assert.equal(entities.values[0].polyline.material.color.isConstant, true);
|
||||
}
|
||||
test('an engine without a pulse backend gets the static ring only', (t) => {
|
||||
const engine = createFakeEngine();
|
||||
const preRender = engine.frames.preRender.numberOfListeners;
|
||||
const ring = ringRendering(t, { engine });
|
||||
const { rendering, pulseFrames } = ring;
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.ok(engine.fake.line(RING_ID));
|
||||
assert.ok(engine.fake.line(RING_HALO_ID));
|
||||
assert.equal(ring.state.visual.disc, null);
|
||||
assert.equal(engine.frames.preRender.numberOfListeners, preRender);
|
||||
assert.equal(getAwarenessEchoField(), null);
|
||||
rendering.clearVisual();
|
||||
assert.equal(pulseFrames(), 0);
|
||||
});
|
||||
@@ -950,8 +991,12 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
|
||||
const loop = frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX);
|
||||
assert.equal(scene.primitives.length, 2, 'the ring and the pulse disc');
|
||||
const { disc, count } = pulsePrimitives(scene);
|
||||
assert.equal(
|
||||
pulsePrimitives(ring).drawn,
|
||||
3,
|
||||
'the ring lines and the pulse disc',
|
||||
);
|
||||
const { disc, count } = pulsePrimitives(ring);
|
||||
assert.equal(count, 1, 'the gradient disc');
|
||||
assert.ok(disc);
|
||||
// Never a click or tap target, and see-through.
|
||||
@@ -1006,7 +1051,7 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
|
||||
const built = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
for (const lon of [3.001, 3.01, 3.1, 3.19]) {
|
||||
rendering.renderVisual(flight(lon));
|
||||
assert.equal(pulsePrimitives(scene).disc, disc, `${lon}`);
|
||||
assert.equal(pulsePrimitives(ring).disc, disc, `${lon}`);
|
||||
assert.ok(!disc.isDestroyed());
|
||||
// Concentric with the ring wherever the subject went, within a metre
|
||||
// across the ground; the ellipsoid curves away from the rotation's
|
||||
@@ -1018,15 +1063,15 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
|
||||
assert.ok(Math.abs(at.height) < 50, `${at.height} m off the ground`);
|
||||
}
|
||||
rendering.renderVisual(flight(3.25)); // ~25 km from where it was built
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
const moved = pulsePrimitives(scene).disc;
|
||||
assert.equal(pulsePrimitives(ring).count, 1);
|
||||
const moved = pulsePrimitives(ring).disc;
|
||||
assert.notEqual(moved, disc);
|
||||
assert.ok(disc.isDestroyed());
|
||||
assert.equal(moved.appearance.material, material);
|
||||
assert.equal(moved.allowPicking, false);
|
||||
|
||||
rendering.clearVisual();
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
assert.equal(pulsePrimitives(ring).drawn, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
@@ -1039,19 +1084,19 @@ test("switching subjects keeps one pulse material in Cesium's global cache", (t)
|
||||
const cached = () =>
|
||||
Object.keys(Cesium.Material._materialCache._materials).length;
|
||||
rendering.renderVisual({ ...flight(3), id: 'subject-0' });
|
||||
const first = pulsePrimitives(scene).disc.appearance.material;
|
||||
const first = pulsePrimitives(ring).disc.appearance.material;
|
||||
const size = cached();
|
||||
let last = first;
|
||||
for (let index = 1; index <= 6; index++) {
|
||||
rendering.renderVisual({ ...flight(3 + index), id: `subject-${index}` });
|
||||
last = pulsePrimitives(scene).disc.appearance.material;
|
||||
last = pulsePrimitives(ring).disc.appearance.material;
|
||||
}
|
||||
assert.equal(cached(), size, 'subject switches add no cache entries');
|
||||
rendering.clearVisual();
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(cached(), size, 'nor does a reselect');
|
||||
// A material built from the cached template still owns its uniform values.
|
||||
const again = pulsePrimitives(scene).disc.appearance.material;
|
||||
const again = pulsePrimitives(ring).disc.appearance.material;
|
||||
assert.notEqual(again, last);
|
||||
assert.notEqual(again.uniforms.radii, last.uniforms.radii);
|
||||
assert.notEqual(again.uniforms.alphas, first.uniforms.alphas);
|
||||
@@ -1066,35 +1111,37 @@ test('a resting pulse disc is not rebuilt as the subject moves, and catches up o
|
||||
const loop = frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500);
|
||||
const resting = pulsePrimitives(scene).disc;
|
||||
const resting = pulsePrimitives(ring).disc;
|
||||
const restingMatrix = Cesium.Matrix4.clone(resting.modelMatrix);
|
||||
// A flying subject keeps moving while the pulses rest: the hidden disc is
|
||||
// neither moved nor rebuilt, even well past the rebuild distance.
|
||||
for (let lon = 3.01; lon < 3.4; lon += 0.01)
|
||||
rendering.renderVisual(flight(lon));
|
||||
assert.equal(pulsePrimitives(scene).disc, resting);
|
||||
assert.equal(pulsePrimitives(ring).disc, resting);
|
||||
assert.ok(!resting.isDestroyed());
|
||||
assert.ok(Cesium.Matrix4.equals(resting.modelMatrix, restingMatrix));
|
||||
// Woken, the disc catches up at once: rebuilt around the subject.
|
||||
rendering.wakePulses();
|
||||
const woken = pulsePrimitives(scene).disc;
|
||||
const woken = pulsePrimitives(ring).disc;
|
||||
assert.notEqual(woken, resting);
|
||||
assert.ok(resting.isDestroyed());
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
assert.equal(pulsePrimitives(ring).count, 1);
|
||||
// Woken again without moving: the disc stays.
|
||||
rendering.wakePulses();
|
||||
assert.equal(pulsePrimitives(scene).disc, woken);
|
||||
assert.equal(pulsePrimitives(ring).disc, woken);
|
||||
});
|
||||
|
||||
test('the ring and pulses never catch a click: no entities, every primitive unpickable', (t) => {
|
||||
for (const reducedMotion of [false, true]) {
|
||||
const { entities, scene, rendering } = ringRendering(t, { reducedMotion });
|
||||
const ring = ringRendering(t, { reducedMotion });
|
||||
const { entities, rendering } = ring;
|
||||
rendering.renderVisual(flight(3));
|
||||
// Cesium always picks entities, so the awareness visual adds none.
|
||||
assert.equal(entities.values.length, 0);
|
||||
assert.ok(scene.primitives.length >= 1);
|
||||
for (let i = 0; i < scene.primitives.length; i++)
|
||||
assert.equal(scene.primitives.get(i).allowPicking, false);
|
||||
const { ring: core, halo, disc } = pulsePrimitives(ring);
|
||||
for (const primitive of [core, halo, disc].filter(Boolean))
|
||||
assert.equal(primitive.allowPicking, false);
|
||||
assert.equal(Boolean(disc), !reducedMotion);
|
||||
rendering.clearVisual();
|
||||
}
|
||||
});
|
||||
@@ -1106,11 +1153,11 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the
|
||||
rendering.renderVisual(flight(3));
|
||||
loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500);
|
||||
const cameraAt = (height) => {
|
||||
scene.camera = {
|
||||
positionCartographic: new Cesium.Cartographic(0, 0, height),
|
||||
};
|
||||
ring.viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(0, 0, height),
|
||||
});
|
||||
scene.preRender.raiseEvent(scene);
|
||||
return pulsePrimitives(scene).disc.show;
|
||||
return pulsePrimitives(ring).disc.show;
|
||||
};
|
||||
assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true);
|
||||
assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view');
|
||||
@@ -1119,7 +1166,7 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9);
|
||||
assert.ok(Math.abs(ringColors(ring).color.alpha - core.alpha) < 1e-9);
|
||||
assert.equal(getAwarenessEchoField().level(loop.now()), 0);
|
||||
const frames = ring.pulseFrames();
|
||||
loop.draw();
|
||||
@@ -1130,14 +1177,15 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the
|
||||
|
||||
test('reduced motion keeps the static ring and drops the pulses', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||
const { scene, rendering, pulseFrames } = ringRendering(t, {
|
||||
const ring = ringRendering(t, {
|
||||
reducedMotion: true,
|
||||
});
|
||||
const { scene, rendering, pulseFrames } = ring;
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.ok(pulsePrimitives(scene).ring);
|
||||
assert.ok(pulsePrimitives(ring).ring);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(pulseFrames(), 0);
|
||||
assert.equal(pulsePrimitives(scene).count, 0, 'no pulse disc');
|
||||
assert.equal(pulsePrimitives(ring).count, 0, 'no pulse disc');
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postRender.numberOfListeners, 0);
|
||||
});
|
||||
@@ -1148,12 +1196,12 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t
|
||||
const loop = frameLoop(t, ring);
|
||||
state.subject = flight(3);
|
||||
rendering.renderVisual(state.subject);
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
assert.equal(pulsePrimitives(ring).count, 1);
|
||||
loop.advance(1000);
|
||||
// The OS setting changes while the same aircraft stays tracked.
|
||||
motion.set(true);
|
||||
assert.ok(pulsePrimitives(scene).ring);
|
||||
assert.equal(pulsePrimitives(scene).count, 0);
|
||||
assert.ok(pulsePrimitives(ring).ring);
|
||||
assert.equal(pulsePrimitives(ring).count, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postRender.numberOfListeners, 0);
|
||||
assert.equal(getAwarenessEchoField(), null);
|
||||
@@ -1161,7 +1209,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t
|
||||
loop.advance(5000);
|
||||
assert.equal(ring.pulseFrames(), frames);
|
||||
motion.set(false);
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
assert.equal(pulsePrimitives(ring).count, 1);
|
||||
assert.ok(getAwarenessEchoField());
|
||||
// One media-query listener per visual; none once the ring is gone.
|
||||
assert.equal(motion.listeners(), 1);
|
||||
@@ -1170,29 +1218,31 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t
|
||||
});
|
||||
|
||||
test('a reduced-motion change reaches older Safari through addListener', (t) => {
|
||||
const { scene, rendering, motion, state } = ringRendering(t, {
|
||||
const ring = ringRendering(t, {
|
||||
legacyQuery: true,
|
||||
});
|
||||
const { scene, rendering, motion, state } = ring;
|
||||
state.subject = flight(3);
|
||||
rendering.renderVisual(state.subject);
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
assert.equal(pulsePrimitives(ring).count, 1);
|
||||
assert.equal(motion.listeners(), 1);
|
||||
motion.set(true);
|
||||
assert.equal(pulsePrimitives(scene).count, 0);
|
||||
assert.equal(pulsePrimitives(ring).count, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
rendering.clearVisual();
|
||||
assert.equal(motion.listeners(), 0);
|
||||
});
|
||||
|
||||
test('a refresh after reduced motion changed rebuilds the visual too', (t) => {
|
||||
const { scene, rendering, motion } = ringRendering(t);
|
||||
const ring = ringRendering(t);
|
||||
const { scene, rendering, motion } = ring;
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
assert.equal(pulsePrimitives(ring).count, 1);
|
||||
// No change event: the next same-subject refresh notices on its own.
|
||||
motion.setQuietly(true);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.ok(pulsePrimitives(scene).ring);
|
||||
assert.equal(pulsePrimitives(scene).count, 0);
|
||||
assert.ok(pulsePrimitives(ring).ring);
|
||||
assert.equal(pulsePrimitives(ring).count, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
@@ -1232,8 +1282,10 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) =>
|
||||
loop.advance(AWARENESS_RANGE_PULSE.settleMs);
|
||||
for (let step = 0; step < 40; step++) {
|
||||
loop.advance(100);
|
||||
const { color, outlineColor } = ringColors(scene);
|
||||
const flash = Math.max(restOpacity, awarenessRingFlash(loop.now()));
|
||||
// As the last frame set it: the pulses draw every 80 ms, and a colour
|
||||
// change asks for a frame, so the ring changes at most that often.
|
||||
const { color, outlineColor } = ringColors(ring, { frame: false });
|
||||
const flash = Math.max(restOpacity, awarenessRingFlash(loop.lastFrame()));
|
||||
const expected = core.alpha * flash;
|
||||
assert.ok(
|
||||
Math.abs(color.alpha - expected) < 1e-9,
|
||||
@@ -1244,11 +1296,12 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) =>
|
||||
});
|
||||
|
||||
test('without pulses the ring stays on screen at full strength', (t) => {
|
||||
const { scene, rendering } = ringRendering(t, { reducedMotion: true });
|
||||
const ring = ringRendering(t, { reducedMotion: true });
|
||||
const { scene, rendering } = ring;
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(
|
||||
ringColors(scene).color.toCssColorString(),
|
||||
ringColors(ring).color.toCssColorString(),
|
||||
Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
).toCssColorString(),
|
||||
@@ -1257,7 +1310,8 @@ test('without pulses the ring stays on screen at full strength', (t) => {
|
||||
});
|
||||
|
||||
test('the echo field follows the subject while pulses run and clears with the ring', (t) => {
|
||||
const { rendering } = ringRendering(t);
|
||||
const ring = ringRendering(t);
|
||||
const { rendering } = ring;
|
||||
rendering.renderVisual(flight(3));
|
||||
const field = getAwarenessEchoField();
|
||||
assert.ok(field);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { BoundingSphere } from 'cesium';
|
||||
|
||||
/**
|
||||
* The awareness layer's view of a Cesium globe. Its range ring, pulse disc
|
||||
* and echoes are Cesium primitives (rendering.js), built on `viewer`.
|
||||
*/
|
||||
export function createAwarenessCesiumRenderer(viewer) {
|
||||
return {
|
||||
viewer,
|
||||
container: viewer.container,
|
||||
get canvas() {
|
||||
return viewer.canvas ?? viewer.scene?.canvas;
|
||||
},
|
||||
get camera() {
|
||||
return viewer.camera;
|
||||
},
|
||||
get trackedEntity() {
|
||||
return viewer.trackedEntity;
|
||||
},
|
||||
// Off while another owner (the cockpit, a gizmo drag, a drawing tool)
|
||||
// has the camera: the user's gesture moves nothing then.
|
||||
cameraInputsEnabled: () =>
|
||||
viewer.scene?.screenSpaceCameraController?.enableInputs !== false,
|
||||
onFrame: (run) => viewer.scene.preRender.addEventListener(run),
|
||||
frameContact: (center, radius, duration) =>
|
||||
viewer.camera.flyToBoundingSphere(new BoundingSphere(center, radius), {
|
||||
duration,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// The range pulses' Cesium backend (render/pulseDisc.js): one flat,
|
||||
// translucent, unpickable disc the size of the range, CONTEXT_RIM_HEIGHT_M
|
||||
// above the ellipsoid, whose material draws each pulse line and the band
|
||||
// behind it. Moving pulses only change its uniforms. The disc follows the
|
||||
// subject by a rotation about the Earth's centre and is rebuilt only past
|
||||
// `gradientRebuildM` from where it was built.
|
||||
import * as Cesium from 'cesium';
|
||||
import { AWARENESS_RANGE_PULSE, CONTEXT_RIM_HEIGHT_M } from '../policy.js';
|
||||
|
||||
/** The pulse disc's Cesium material type, one cached template per page. */
|
||||
const MATERIAL_TYPE = 'GevAwarenessPulseGradient';
|
||||
|
||||
// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at
|
||||
// distance 0.5, so `r` is the fraction of the range. Each pulse is a line
|
||||
// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half
|
||||
// on each side, as Cesium's polyline outline material draws it, with a band
|
||||
// behind it that is strongest at the line and fades to nothing `depth`
|
||||
// inward. Widths come from the screen-space gradient of `r`, as Cesium's
|
||||
// contour material measures its lines; without derivatives the lines are
|
||||
// left out and only the band shows.
|
||||
const SOURCE = `
|
||||
uniform vec4 color;
|
||||
uniform vec4 lineColor;
|
||||
uniform vec4 haloColor;
|
||||
uniform vec4 radii;
|
||||
uniform vec4 alphas;
|
||||
uniform float depth;
|
||||
uniform float lineWidth;
|
||||
uniform float haloWidth;
|
||||
vec4 over(vec4 top, vec4 under) {
|
||||
float a = top.a + under.a * (1.0 - top.a);
|
||||
if (a <= 0.0) return vec4(0.0);
|
||||
return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a);
|
||||
}
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float r = length(materialInput.st - vec2(0.5)) * 2.0;
|
||||
float band = 0.0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float inside = radii[i] - r;
|
||||
if (inside >= 0.0 && inside <= depth) {
|
||||
float t = 1.0 - inside / depth;
|
||||
band += alphas[i] * t * t;
|
||||
}
|
||||
}
|
||||
vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0));
|
||||
#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives))
|
||||
// Change in r per CSS pixel: the gradient's length per device pixel,
|
||||
// times the device pixels in one CSS pixel.
|
||||
float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio;
|
||||
float outerHalf = lineWidth * 0.5;
|
||||
float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float d = abs(radii[i] - r) / cssPx;
|
||||
float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0);
|
||||
if (cover <= 0.0) continue;
|
||||
float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0);
|
||||
vec4 pulse = mix(haloColor, lineColor, core);
|
||||
result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result);
|
||||
}
|
||||
#endif
|
||||
material.diffuse = result.rgb;
|
||||
material.alpha = result.a;
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
|
||||
const toColor = ([red, green, blue, alpha]) =>
|
||||
new Cesium.Color(red, green, blue, alpha);
|
||||
|
||||
/**
|
||||
* The rotation about the Earth's centre that carries `from` onto `to`, as a
|
||||
* model matrix, so a disc built around `from` sits around `to`.
|
||||
* @param {Cesium.Cartesian3} from Where the disc was built.
|
||||
* @param {Cesium.Cartesian3} to Where it should be.
|
||||
* @param {Cesium.Matrix4} [result]
|
||||
* @returns {Cesium.Matrix4}
|
||||
*/
|
||||
export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
|
||||
const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3());
|
||||
const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3());
|
||||
const sin = Cesium.Cartesian3.magnitude(axis);
|
||||
if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result);
|
||||
const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b));
|
||||
const rotation = Cesium.Matrix3.fromQuaternion(
|
||||
Cesium.Quaternion.fromAxisAngle(
|
||||
Cesium.Cartesian3.divideByScalar(axis, sin, axis),
|
||||
angle,
|
||||
),
|
||||
);
|
||||
return Cesium.Matrix4.fromRotationTranslation(
|
||||
rotation,
|
||||
Cesium.Cartesian3.ZERO,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function createDisc(center, radiusM, material) {
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.EllipseGeometry({
|
||||
center,
|
||||
semiMajorAxis: radiusM,
|
||||
semiMinorAxis: radiusM,
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
granularity: Cesium.Math.toRadians(
|
||||
AWARENESS_RANGE_PULSE.gradientGranularityDeg,
|
||||
),
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material,
|
||||
flat: true,
|
||||
translucent: true,
|
||||
faceForward: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The pulse disc on a Cesium viewer, or null when its scene has no
|
||||
* primitive collection: `draw({ radii, alphas, level })`, `move(center)`,
|
||||
* `destroy()`. Colours are rgba arrays in 0..1.
|
||||
* @param {{ viewer: object }} cesium The engine's Cesium extension.
|
||||
*/
|
||||
export function createAwarenessCesiumPulses(
|
||||
{ viewer },
|
||||
{ center, radiusM, color, lineColor, haloColor, depth, lineWidth, haloWidth },
|
||||
) {
|
||||
const primitives = viewer?.scene?.primitives;
|
||||
if (!primitives?.add) return null;
|
||||
const uniforms = {
|
||||
color: toColor(color),
|
||||
lineColor: toColor(lineColor),
|
||||
haloColor: toColor(haloColor),
|
||||
radii: new Cesium.Cartesian4(),
|
||||
alphas: new Cesium.Cartesian4(),
|
||||
depth,
|
||||
lineWidth,
|
||||
haloWidth,
|
||||
};
|
||||
// A named type: Cesium caches every material type for the page's
|
||||
// lifetime, so an unnamed fabric (a fresh GUID each time) would leave one
|
||||
// entry per new visual. Built from the cached template, the uniforms come
|
||||
// back as plain copies; restore this disc's own.
|
||||
const material = new Cesium.Material({
|
||||
fabric: { type: MATERIAL_TYPE, uniforms, source: SOURCE },
|
||||
translucent: true,
|
||||
});
|
||||
Object.assign(material.uniforms, uniforms);
|
||||
const built = Cesium.Cartesian3.clone(center);
|
||||
let disc = primitives.add(createDisc(built, radiusM, material));
|
||||
let destroyed = false;
|
||||
return {
|
||||
draw({ radii, alphas, level }) {
|
||||
if (destroyed) return;
|
||||
disc.show = level > 0;
|
||||
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
|
||||
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
|
||||
},
|
||||
/**
|
||||
* Centres the disc on `next`: within `gradientRebuildM` of where it was
|
||||
* built by its model matrix (the ellipsoid curves a little differently
|
||||
* from that sphere, tens of metres in height at 20 km), further away by
|
||||
* a rebuild, which costs a few milliseconds.
|
||||
*/
|
||||
move(next) {
|
||||
if (destroyed) return;
|
||||
if (
|
||||
Cesium.Cartesian3.distance(built, next) <
|
||||
AWARENESS_RANGE_PULSE.gradientRebuildM
|
||||
) {
|
||||
disc.modelMatrix = discFollowMatrix(built, next, disc.modelMatrix);
|
||||
return;
|
||||
}
|
||||
const previous = disc;
|
||||
Cesium.Cartesian3.clone(next, built);
|
||||
disc = primitives.add(createDisc(built, radiusM, material));
|
||||
disc.show = previous.show;
|
||||
// Primitive.destroy leaves the material alone, so the new disc keeps
|
||||
// sharing it.
|
||||
primitives.remove(previous);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
primitives.remove(disc);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// The Global Context visual on the globe contract (engine.lines,
|
||||
// engine.camera, engine.frames, engine.host), the same on every engine:
|
||||
// the layer's view of the globe (its canvas, camera, frames, the followed
|
||||
// contact and a contact flight) and the 250 km range ring, a core line over
|
||||
// a wider dark halo at the rim height, with straight chords between
|
||||
// positions that already follow the surface. The pulses inside the ring sit
|
||||
// behind ./pulseDisc.js. Nothing here takes picks.
|
||||
//
|
||||
// Cesium's ring was one polyline in its outline material: a 4 px line whose
|
||||
// inner 3 px are the core and whose outer pixel is the halo, half on each
|
||||
// side. Lines have one colour, so here the halo is a 4 px line and the core
|
||||
// a 3 px line drawn over it.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js';
|
||||
import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../../cyberSonar.js';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import {
|
||||
AWARENESS_RANGE_PULSE,
|
||||
AWARENESS_RANGE_RING_STYLE,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
} from '../policy.js';
|
||||
import { awarenessRangeRingPositions } from '../rendering.js';
|
||||
import { createAwarenessPulseField } from './pulseDisc.js';
|
||||
|
||||
export const RING_ID = 'gev-awareness-rim';
|
||||
export const RING_HALO_ID = 'gev-awareness-rim-halo';
|
||||
|
||||
/** `rgba(r, g, b, a)` as an rgba array in 0..1, as Cesium reads it. */
|
||||
function rgbaArray(css) {
|
||||
const [r, g, b, a = 1] = css.match(/[\d.]+/g).map(Number);
|
||||
return [r / 255, g / 255, b / 255, a];
|
||||
}
|
||||
const RING_CORE = rgbaArray(AWARENESS_RANGE_RING_STYLE.color);
|
||||
const RING_HALO = rgbaArray(AWARENESS_RANGE_RING_STYLE.outlineColor);
|
||||
const withAlpha = (rgba, alpha) => [rgba[0], rgba[1], rgba[2], rgba[3] * alpha];
|
||||
|
||||
/**
|
||||
* The layer's view of `engine`.
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ navaraPulses?: Function }} [options] The pulses' Navara backend
|
||||
* (render/navaraPulses.js), which the Navara mode passes in.
|
||||
*/
|
||||
export function createAwarenessDisplay(engine, { navaraPulses } = {}) {
|
||||
if (!engine?.lines)
|
||||
throw new TypeError('The awareness display needs a globe engine');
|
||||
const binding = createEngineLayerView(engine, {
|
||||
layerId: 'military-awareness',
|
||||
});
|
||||
const style = AWARENESS_RANGE_RING_STYLE;
|
||||
return {
|
||||
container: engine.host.container,
|
||||
canvas: engine.host.canvas,
|
||||
// Camera-shaped: positionWC, heading, rightWC, upWC, refreshed per frame.
|
||||
camera: binding.view.camera,
|
||||
get trackedEntity() {
|
||||
return engine.camera.following()?.native;
|
||||
},
|
||||
cameraHeight: () => engine.camera.height(),
|
||||
onFrame: (run) => engine.frames.preRender.addEventListener(run),
|
||||
onFrameEnd: (run) => engine.frames.postRender.addEventListener(run),
|
||||
frameContact: (center, radius, duration) =>
|
||||
engine.camera.flyToSphere({ center, radius }, { duration }),
|
||||
|
||||
/**
|
||||
* The ring around `center` (on the ellipsoid); `setAlpha` flashes it.
|
||||
* Every colour change asks the engine for a frame, so while it fades a
|
||||
* flash changes at most once per Cyber sonar interval, the pulses' own
|
||||
* cadence; full strength is set at once.
|
||||
*/
|
||||
createRim(center) {
|
||||
const halo = engine.lines.createLine({
|
||||
id: RING_HALO_ID,
|
||||
color: RING_HALO,
|
||||
widthPx: style.width,
|
||||
order: 0,
|
||||
});
|
||||
const core = engine.lines.createLine({
|
||||
id: RING_ID,
|
||||
color: RING_CORE,
|
||||
widthPx: Math.max(1, style.width - style.outlineWidth),
|
||||
order: 1,
|
||||
});
|
||||
let alpha = 1;
|
||||
let changedAt = Number.NEGATIVE_INFINITY;
|
||||
const move = (position) => {
|
||||
const positions = awarenessRangeRingPositions(
|
||||
position,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
halo.setPositions(positions);
|
||||
core.setPositions(positions);
|
||||
};
|
||||
move(center);
|
||||
return {
|
||||
move,
|
||||
setAlpha(next) {
|
||||
if (next === alpha) return;
|
||||
const now = performance.now();
|
||||
if (next !== 1 && now - changedAt < CYBER_SONAR_RENDER_INTERVAL_MS)
|
||||
return;
|
||||
alpha = next;
|
||||
changedAt = now;
|
||||
halo.setColor(withAlpha(RING_HALO, alpha));
|
||||
core.setColor(withAlpha(RING_CORE, alpha));
|
||||
},
|
||||
destroy() {
|
||||
core.destroy();
|
||||
halo.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/** The pulse disc around `center`, or null on an engine without one. */
|
||||
createPulses: (center) =>
|
||||
createAwarenessPulseField(
|
||||
engine,
|
||||
{
|
||||
center: geo.cartesian3.clone(center),
|
||||
radiusM: AWARENESS_RADIUS_M,
|
||||
// The band's own opacity, as the pulse material sets it.
|
||||
color: [
|
||||
...RING_CORE.slice(0, 3),
|
||||
AWARENESS_RANGE_PULSE.gradientOpacity,
|
||||
],
|
||||
lineColor: RING_CORE,
|
||||
haloColor: RING_HALO,
|
||||
depth: AWARENESS_RANGE_PULSE.gradientDepthFraction,
|
||||
lineWidth: AWARENESS_RANGE_PULSE.width,
|
||||
haloWidth: AWARENESS_RANGE_PULSE.outlineWidth,
|
||||
},
|
||||
{ navaraPulses },
|
||||
),
|
||||
destroy: binding.destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// The Global Context display on the engine contract, on the fake engine and
|
||||
// on the Cesium engine: the camera it reads, the followed contact, a contact
|
||||
// flight, its frames, the ring lines and the pulse disc only where the
|
||||
// engine has a backend for it.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { createAwarenessDisplay, RING_HALO_ID, RING_ID } from './display.js';
|
||||
|
||||
const HARNESSES = [
|
||||
() => ({ name: 'the fake engine', engine: createFakeEngine() }),
|
||||
() => {
|
||||
const { engine, viewer, primitive } = createCesiumMoversEngine();
|
||||
return {
|
||||
name: 'the Cesium engine',
|
||||
cesium: true,
|
||||
engine,
|
||||
viewer,
|
||||
primitive,
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
for (const makeHarness of HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`the display reads the engine's camera, frames and followed contact (${name})`, (t) => {
|
||||
const h = makeHarness();
|
||||
const display = createAwarenessDisplay(h.engine);
|
||||
t.after(() => {
|
||||
display.destroy();
|
||||
h.engine.destroy();
|
||||
});
|
||||
h.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(3, 20, 800_000),
|
||||
orientation: { heading: 0.5, pitch: -1, roll: 0 },
|
||||
});
|
||||
assert.equal(display.container, h.engine.host.container);
|
||||
assert.equal(display.canvas, h.engine.host.canvas);
|
||||
assert.ok(Math.abs(display.camera.heading - 0.5) < 1e-9);
|
||||
assert.ok(
|
||||
geo.cartesian3.distance(
|
||||
display.camera.positionWC,
|
||||
geo.cartesian3.fromDegrees(3, 20, 800_000),
|
||||
) < 1e-3,
|
||||
);
|
||||
assert.ok(Math.abs(display.cameraHeight() - 800_000) < 1e-3);
|
||||
|
||||
const contact = { gevTrackedId: 'flights:abc' };
|
||||
h.engine.camera.follow({
|
||||
native: contact,
|
||||
position: () => geo.cartesian3.fromDegrees(3, 20, 9000),
|
||||
viewFrom: geo.cartesian3.create(0, -2000, 1000),
|
||||
});
|
||||
assert.equal(display.trackedEntity?.gevTrackedId, 'flights:abc');
|
||||
h.engine.camera.following()?.stop();
|
||||
|
||||
let frames = 0,
|
||||
ends = 0;
|
||||
const offFrame = display.onFrame(() => frames++);
|
||||
const offEnd = display.onFrameEnd(() => ends++);
|
||||
if (h.cesium) {
|
||||
h.viewer.scene.preRender.raiseEvent(h.viewer.scene);
|
||||
h.viewer.scene.postRender.raiseEvent(h.viewer.scene);
|
||||
} else h.engine.fake.frame();
|
||||
assert.deepEqual([frames, ends], [1, 1]);
|
||||
offFrame();
|
||||
offEnd();
|
||||
|
||||
const flights = [];
|
||||
h.engine.camera.flyToSphere = (sphere, options) =>
|
||||
flights.push({ sphere, options });
|
||||
const contactAt = geo.cartesian3.fromDegrees(3, 20, 0);
|
||||
display.frameContact(contactAt, 3000, 1.4);
|
||||
assert.deepEqual(flights, [
|
||||
{
|
||||
sphere: { center: contactAt, radius: 3000 },
|
||||
options: { duration: 1.4 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test(`the ring is a 3 px core over a 4 px halo; pulses only with a backend (${name})`, (t) => {
|
||||
const h = makeHarness();
|
||||
const display = createAwarenessDisplay(h.engine);
|
||||
t.after(() => {
|
||||
display.destroy();
|
||||
h.engine.destroy();
|
||||
});
|
||||
const center = geo.cartesian3.fromDegrees(3, 27, 0);
|
||||
const rim = display.createRim(center);
|
||||
const disc = display.createPulses(center);
|
||||
if (h.cesium) {
|
||||
const core = h.primitive(RING_ID);
|
||||
const halo = h.primitive(RING_HALO_ID);
|
||||
assert.equal(core.geometryInstances.geometry._width, 3);
|
||||
assert.equal(halo.geometryInstances.geometry._width, 4);
|
||||
assert.equal(core.allowPicking, false);
|
||||
assert.ok(disc, 'the Cesium backend draws the pulse disc');
|
||||
const primitives = Array.from(
|
||||
{ length: h.viewer.scene.primitives.length },
|
||||
(_, i) => h.viewer.scene.primitives.get(i),
|
||||
);
|
||||
const shown = primitives.find(
|
||||
(p) => p.appearance instanceof Cesium.MaterialAppearance,
|
||||
);
|
||||
assert.equal(shown.allowPicking, false);
|
||||
disc.draw({ radii: [0.5, 0, 0, 0], alphas: [0.4, 0, 0, 0], level: 1 });
|
||||
assert.equal(shown.show, true);
|
||||
assert.equal(shown.appearance.material.uniforms.radii.x, 0.5);
|
||||
disc.draw({ radii: [0, 0, 0, 0], alphas: [0, 0, 0, 0], level: 0 });
|
||||
assert.equal(shown.show, false);
|
||||
disc.destroy();
|
||||
assert.ok(shown.isDestroyed());
|
||||
} else {
|
||||
assert.equal(h.engine.fake.line(RING_ID).options.widthPx, 3);
|
||||
assert.equal(h.engine.fake.line(RING_HALO_ID).options.widthPx, 4);
|
||||
assert.equal(disc, null, 'the fake engine has no pulse backend');
|
||||
}
|
||||
rim.setAlpha(0.4);
|
||||
const alpha = h.cesium
|
||||
? h.primitive(RING_ID).appearance.material.uniforms.color.alpha
|
||||
: h.engine.fake.line(RING_ID).color[3];
|
||||
assert.ok(Math.abs(alpha - 0.95 * 0.4) < 1e-9);
|
||||
rim.destroy();
|
||||
assert.equal(
|
||||
h.engine.lines.list().filter(({ id }) => id.startsWith(RING_ID)).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js';
|
||||
import {
|
||||
AWARENESS_RANGE_PULSE,
|
||||
AWARENESS_RANGE_RING_STYLE,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
} from '../policy.js';
|
||||
import { createAwarenessPulseDisc } from './navaraPulses.js';
|
||||
|
||||
/** `rgba(r, g, b, a)` as an rgba array in 0..1. */
|
||||
function rgbaArray(css) {
|
||||
const [r, g, b, a = 1] = css.match(/[\d.]+/g).map(Number);
|
||||
return [r / 255, g / 255, b / 255, a];
|
||||
}
|
||||
const RING_CORE = rgbaArray(AWARENESS_RANGE_RING_STYLE.color);
|
||||
const RING_HALO = rgbaArray(AWARENESS_RANGE_RING_STYLE.outlineColor);
|
||||
const withAlpha = (rgba, alpha) => [rgba[0], rgba[1], rgba[2], rgba[3] * alpha];
|
||||
|
||||
// Sample in the subject's east/north plane, then lift each vertex onto
|
||||
// the ellipsoid. This also works when a ring crosses the date line or a pole.
|
||||
function rimPositions(center) {
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(center);
|
||||
const positions = [];
|
||||
for (let i = 0; i < 180; i++) {
|
||||
const angle = (i * Math.PI) / 90;
|
||||
const local = geo.cartesian3.create(
|
||||
Math.cos(angle) * AWARENESS_RADIUS_M,
|
||||
Math.sin(angle) * AWARENESS_RADIUS_M,
|
||||
0,
|
||||
);
|
||||
const world = geo.matrix4.multiplyByPoint(
|
||||
frame,
|
||||
local,
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
const point = geo.cartographic.fromCartesian(world);
|
||||
positions.push(
|
||||
geo.cartesian3.fromRadians(
|
||||
point.longitude,
|
||||
point.latitude,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
),
|
||||
);
|
||||
}
|
||||
positions.push(positions[0]);
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The awareness layer's view of a Navara globe. With the globe's native view
|
||||
* (`native`: view, EffectDesc and NavaraEffect) it also draws the range
|
||||
* pulses; without one, the range ring alone.
|
||||
*/
|
||||
export function createAwarenessNavaraRenderer(engine, native = null) {
|
||||
const binding = createEngineLayerView(engine, {
|
||||
layerId: 'military-awareness',
|
||||
});
|
||||
const style = AWARENESS_RANGE_RING_STYLE;
|
||||
return {
|
||||
container: engine.host.container,
|
||||
canvas: engine.host.canvas,
|
||||
camera: binding.view.camera,
|
||||
get trackedEntity() {
|
||||
return engine.camera.following()?.native;
|
||||
},
|
||||
cameraHeight: () => engine.camera.height(),
|
||||
onFrame: (run) => binding.view.on('preRender', run),
|
||||
onFrameEnd: (run) => engine.frames.postRender.addEventListener(run),
|
||||
frameContact: (center, radius, duration) =>
|
||||
engine.camera.flyToSphere({ center, radius }, { duration }),
|
||||
/**
|
||||
* The 250 km ring: a core line over a wider dark halo, as the Cesium
|
||||
* ring's outline material draws it; `setAlpha` flashes it.
|
||||
*/
|
||||
createRim(center) {
|
||||
const halo = engine.lines.createLine({
|
||||
id: 'gev-awareness-rim-halo',
|
||||
color: RING_HALO,
|
||||
widthPx: style.width,
|
||||
arc: 'geodesic',
|
||||
});
|
||||
const core = engine.lines.createLine({
|
||||
id: 'gev-awareness-rim',
|
||||
color: RING_CORE,
|
||||
widthPx: Math.max(1, style.width - 2 * style.outlineWidth),
|
||||
arc: 'geodesic',
|
||||
});
|
||||
let alpha = 1;
|
||||
const move = (position) => {
|
||||
const positions = rimPositions(position);
|
||||
halo.setPositions(positions);
|
||||
core.setPositions(positions);
|
||||
};
|
||||
move(center);
|
||||
return {
|
||||
move,
|
||||
setAlpha(next) {
|
||||
if (next === alpha) return;
|
||||
alpha = next;
|
||||
halo.setColor(withAlpha(RING_HALO, alpha));
|
||||
core.setColor(withAlpha(RING_CORE, alpha));
|
||||
},
|
||||
destroy() {
|
||||
core.destroy();
|
||||
halo.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
// The pulse disc, when the native view can draw it.
|
||||
...(native?.view && native.EffectDesc && native.NavaraEffect
|
||||
? {
|
||||
createPulses: (center) =>
|
||||
createAwarenessPulseDisc(engine, native, {
|
||||
center,
|
||||
radiusM: AWARENESS_RADIUS_M,
|
||||
// The band's own opacity, as the Cesium material sets it.
|
||||
color: [
|
||||
...RING_CORE.slice(0, 3),
|
||||
AWARENESS_RANGE_PULSE.gradientOpacity,
|
||||
],
|
||||
lineColor: RING_CORE,
|
||||
haloColor: RING_HALO,
|
||||
depth: AWARENESS_RANGE_PULSE.gradientDepthFraction,
|
||||
lineWidth: AWARENESS_RANGE_PULSE.width,
|
||||
haloWidth: AWARENESS_RANGE_PULSE.outlineWidth,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
destroy: binding.destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// The Global Context range pulses behind one seam: a disc the size of the
|
||||
// range whose shader draws up to four pulse lines, their halos and the band
|
||||
// fading inward behind each, with fronts and opacities that change every
|
||||
// frame. The globe contract has no primitive for an animated fill (a ground
|
||||
// set rebuilds on every commit), so each engine draws it through its own
|
||||
// backend: render/cesiumPulses.js (a material disc at the rim height) and
|
||||
// render/navaraPulses.js (a surface pass in Navara's view), which the Navara
|
||||
// mode loads and passes in, as Navara modules reach the app only through
|
||||
// its import(). This is the only awareness module that reads
|
||||
// engine.extensions.
|
||||
//
|
||||
// An engine with no backend (the fake engine) draws no pulses, and the
|
||||
// layer keeps its range ring static there.
|
||||
|
||||
import { createAwarenessCesiumPulses } from './cesiumPulses.js';
|
||||
|
||||
/**
|
||||
* The pulse disc around `center` on `engine`, or null when the engine has no
|
||||
* backend: `draw({ radii, alphas, level })` each frame with the fronts as
|
||||
* fractions of `radiusM` and their opacities, `move(center)`, `destroy()`.
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {object} disc The disc: `center`, `radiusM`, rgba `color` (the
|
||||
* band), `lineColor`, `haloColor`, `depth`, `lineWidth`, `haloWidth`.
|
||||
* @param {{ navaraPulses?: (engine: object, navara: object, disc: object) => object }} [backends]
|
||||
* `navaraPulses` is the Navara backend's factory (render/navaraPulses.js).
|
||||
*/
|
||||
export function createAwarenessPulseField(engine, disc, { navaraPulses } = {}) {
|
||||
const cesium = engine.extensions?.cesium;
|
||||
const navara = engine.extensions?.navara;
|
||||
if (cesium) return createAwarenessCesiumPulses(cesium, disc);
|
||||
if (navara?.view && navara.EffectDesc && navara.NavaraEffect && navaraPulses)
|
||||
return navaraPulses(engine, navara, disc);
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
@@ -31,11 +30,12 @@ import {
|
||||
* raised to `heightM`. Each bearing steps over a sphere with the ellipsoid's
|
||||
* radius of curvature in that direction (Euler's formula), which keeps the
|
||||
* ring within ~50 m of the true geodesic radius at 250 km.
|
||||
* @param {Cesium.Cartesian3} groundCenter Ring centre on the ellipsoid.
|
||||
* @param {{x: number, y: number, z: number}} groundCenter Ring centre on the ellipsoid.
|
||||
* @param {number} radiusM Surface radius in metres.
|
||||
* @param {number} heightM Height of the ring above the ellipsoid.
|
||||
* @param {number} [segments] Chords around the ring.
|
||||
* @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first.
|
||||
* @returns {Array<{x: number, y: number, z: number}>} `segments + 1`
|
||||
* positions; the last repeats the first.
|
||||
*/
|
||||
export function awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
@@ -43,9 +43,8 @@ export function awarenessRangeRingPositions(
|
||||
heightM,
|
||||
segments = AWARENESS_RANGE_RING_STYLE.segments,
|
||||
) {
|
||||
const { latitude, longitude } =
|
||||
Cesium.Cartographic.fromCartesian(groundCenter);
|
||||
const { x: a, z: b } = Cesium.Ellipsoid.WGS84.radii;
|
||||
const { latitude, longitude } = geo.cartographic.fromCartesian(groundCenter);
|
||||
const { x: a, z: b } = geo.ellipsoid.WGS84.radii;
|
||||
const e2 = 1 - (b * b) / (a * a);
|
||||
const sinLat = Math.sin(latitude);
|
||||
const cosLat = Math.cos(latitude);
|
||||
@@ -62,7 +61,7 @@ export function awarenessRangeRingPositions(
|
||||
const east = [-sinLon, cosLon, 0];
|
||||
const positions = [];
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI;
|
||||
const bearing = ((i % segments) / segments) * geo.math.TWO_PI;
|
||||
const cosB = Math.cos(bearing);
|
||||
const sinB = Math.sin(bearing);
|
||||
const angular =
|
||||
@@ -74,7 +73,7 @@ export function awarenessRangeRingPositions(
|
||||
);
|
||||
const lat = Math.atan2(z, Math.hypot(x, y));
|
||||
const lon = Math.atan2(y, x);
|
||||
positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM));
|
||||
positions.push(geo.cartesian3.fromRadians(lon, lat, heightM));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
@@ -175,177 +174,9 @@ export function awarenessRingFlash(nowMs) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Most pulses the pulse shader draws; its uniforms are vec4s. */
|
||||
/** Most pulses the pulse disc draws; its shader uniforms are vec4s. */
|
||||
export const AWARENESS_PULSE_GRADIENT_MAX = 4;
|
||||
|
||||
/** The pulse disc's Cesium material type, one cached template per page. */
|
||||
const AWARENESS_PULSE_MATERIAL_TYPE = 'GevAwarenessPulseGradient';
|
||||
|
||||
// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at
|
||||
// distance 0.5, so `r` is the fraction of the range. Each pulse is a line
|
||||
// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half
|
||||
// on each side, as Cesium's polyline outline material draws it, with a band
|
||||
// behind it that is strongest at the line and fades to nothing `depth`
|
||||
// inward. Widths come from the screen-space gradient of `r`, as Cesium's
|
||||
// contour material measures its lines; without derivatives the lines are
|
||||
// left out and only the band shows.
|
||||
const PULSE_GRADIENT_SOURCE = `
|
||||
uniform vec4 color;
|
||||
uniform vec4 lineColor;
|
||||
uniform vec4 haloColor;
|
||||
uniform vec4 radii;
|
||||
uniform vec4 alphas;
|
||||
uniform float depth;
|
||||
uniform float lineWidth;
|
||||
uniform float haloWidth;
|
||||
vec4 over(vec4 top, vec4 under) {
|
||||
float a = top.a + under.a * (1.0 - top.a);
|
||||
if (a <= 0.0) return vec4(0.0);
|
||||
return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a);
|
||||
}
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float r = length(materialInput.st - vec2(0.5)) * 2.0;
|
||||
float band = 0.0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float inside = radii[i] - r;
|
||||
if (inside >= 0.0 && inside <= depth) {
|
||||
float t = 1.0 - inside / depth;
|
||||
band += alphas[i] * t * t;
|
||||
}
|
||||
}
|
||||
vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0));
|
||||
#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives))
|
||||
// Change in r per CSS pixel: the gradient's length per device pixel,
|
||||
// times the device pixels in one CSS pixel.
|
||||
float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio;
|
||||
float outerHalf = lineWidth * 0.5;
|
||||
float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float d = abs(radii[i] - r) / cssPx;
|
||||
float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0);
|
||||
if (cover <= 0.0) continue;
|
||||
float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0);
|
||||
vec4 pulse = mix(haloColor, lineColor, core);
|
||||
result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result);
|
||||
}
|
||||
#endif
|
||||
material.diffuse = result.rgb;
|
||||
material.alpha = result.a;
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The pulses: one flat, translucent, unpickable disc the size of the range
|
||||
* ring whose shader draws each pulse line and the band behind it. Drawing
|
||||
* them here rather than as entities keeps them out of picking, so clicks and
|
||||
* taps near a pulse still reach the contact underneath. Moving pulses only
|
||||
* change its uniforms; the disc is rebuilt only when the subject moves.
|
||||
* @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid.
|
||||
* @param {Cesium.Material} material Shared pulse-gradient material.
|
||||
* @returns {Cesium.Primitive}
|
||||
*/
|
||||
function createPulseGradient(groundCenter, material) {
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.EllipseGeometry({
|
||||
center: groundCenter,
|
||||
semiMajorAxis: AWARENESS_RADIUS_M,
|
||||
semiMinorAxis: AWARENESS_RADIUS_M,
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
granularity: Cesium.Math.toRadians(
|
||||
AWARENESS_RANGE_PULSE.gradientGranularityDeg,
|
||||
),
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material,
|
||||
flat: true,
|
||||
translucent: true,
|
||||
faceForward: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring's polyline geometry: `AWARENESS_RANGE_RING_STYLE.width` screen
|
||||
* pixels, straight chords between the given positions (they already follow
|
||||
* the surface), in the material appearance's vertex format.
|
||||
* @param {Cesium.Cartesian3[]} positions Ring positions.
|
||||
* @returns {object} PolylineGeometry options.
|
||||
*/
|
||||
export function awarenessRingGeometryOptions(positions) {
|
||||
return {
|
||||
positions,
|
||||
width: AWARENESS_RANGE_RING_STYLE.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The 250 km ring as an unpickable polyline primitive in the ring's outline
|
||||
* material, so a click or tap on its edge reaches the contact underneath.
|
||||
* Its positions are geometry, so a moved subject rebuilds it; the material
|
||||
* is shared between builds so a flash keeps going.
|
||||
* @param {Cesium.Cartesian3[]} positions Ring positions.
|
||||
* @param {Cesium.Material} material Shared PolylineOutline material.
|
||||
* @returns {Cesium.Primitive}
|
||||
*/
|
||||
function createRingPrimitive(positions, material) {
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.PolylineGeometry(
|
||||
awarenessRingGeometryOptions(positions),
|
||||
),
|
||||
}),
|
||||
appearance: new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The rotation about the Earth's centre that carries `from` onto `to`, as a
|
||||
* model matrix, so a disc built around `from` sits around `to`.
|
||||
* @param {Cesium.Cartesian3} from Where the disc was built.
|
||||
* @param {Cesium.Cartesian3} to Where it should be.
|
||||
* @param {Cesium.Matrix4} [result]
|
||||
* @returns {Cesium.Matrix4}
|
||||
*/
|
||||
export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
|
||||
const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3());
|
||||
const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3());
|
||||
const sin = Cesium.Cartesian3.magnitude(axis);
|
||||
if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result);
|
||||
const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b));
|
||||
const rotation = Cesium.Matrix3.fromQuaternion(
|
||||
Cesium.Quaternion.fromAxisAngle(
|
||||
Cesium.Cartesian3.divideByScalar(axis, sin, axis),
|
||||
angle,
|
||||
),
|
||||
);
|
||||
return Cesium.Matrix4.fromRotationTranslation(
|
||||
rotation,
|
||||
Cesium.Cartesian3.ZERO,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
/** Camera height above the ellipsoid, or NaN when the scene cannot say. */
|
||||
function cameraHeightM(scene) {
|
||||
return scene.camera?.positionCartographic?.height ?? Number.NaN;
|
||||
}
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -607,64 +438,24 @@ export function createRendering({
|
||||
/**
|
||||
* Run the range pulses again for `activeMs`, after a camera move. Call it
|
||||
* during a frame (lifecycle's preRender): that frame's postRender arms the
|
||||
* next request, so waking asks for no extra frame of its own.
|
||||
* next request, so waking asks for no extra frame of its own. A resting
|
||||
* disc was left where it was; it catches up with the subject now.
|
||||
*/
|
||||
function wakePulses() {
|
||||
const wake = layerState.visual?.wake;
|
||||
if (!wake) return;
|
||||
wakeAwarenessPulses(wake, performance.now());
|
||||
followPulseDisc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the pulse disc centred on the subject. Within `gradientRebuildM` of
|
||||
* where it was built, the disc is rotated about the Earth's centre onto
|
||||
* the subject by its model matrix, which keeps it concentric with the ring
|
||||
* at no build cost; the ellipsoid curves a little differently from that
|
||||
* sphere, a few tens of metres in height at 20 km. Further away it is
|
||||
* rebuilt, which costs a few milliseconds.
|
||||
*/
|
||||
function followPulseDisc() {
|
||||
const { gradient, gradientCenter, center } = layerState.visual;
|
||||
if (!gradient) return;
|
||||
if (
|
||||
Cesium.Cartesian3.distance(gradientCenter, center) <
|
||||
AWARENESS_RANGE_PULSE.gradientRebuildM
|
||||
) {
|
||||
gradient.modelMatrix = discFollowMatrix(
|
||||
gradientCenter,
|
||||
center,
|
||||
gradient.modelMatrix,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const primitives = layerState.display.viewer.scene.primitives;
|
||||
layerState.visual.gradient = primitives.add(
|
||||
createPulseGradient(center, gradient.appearance.material),
|
||||
);
|
||||
// Primitive.destroy leaves the material alone, so the new disc keeps
|
||||
// sharing it.
|
||||
primitives.remove(gradient);
|
||||
Cesium.Cartesian3.clone(center, gradientCenter);
|
||||
const visual = layerState.visual;
|
||||
if (!visual?.wake) return;
|
||||
wakeAwarenessPulses(visual.wake, performance.now());
|
||||
visual.disc?.move(visual.center);
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
const visual = layerState.visual;
|
||||
const viewer = layerState.display?.viewer;
|
||||
visual?.stopPulses?.();
|
||||
visual?.removeMotionListener?.();
|
||||
if (visual?.gradient && viewer)
|
||||
viewer.scene.primitives.remove(visual.gradient);
|
||||
if (visual?.ringPrimitive && viewer) {
|
||||
viewer.scene.primitives.remove?.(visual.ringPrimitive);
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
clearAwarenessEchoField();
|
||||
if (visual?.rim || (visual?.entities && viewer)) {
|
||||
visual.rim?.destroy();
|
||||
if (visual?.rim) {
|
||||
visual.rim.destroy();
|
||||
visual.disc?.destroy();
|
||||
for (const entity of visual.entities ?? [])
|
||||
viewer.entities.remove(entity);
|
||||
// Idle mode renders only on request; a removed ring must not linger.
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -777,27 +568,26 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual through the display of an engine without a Cesium viewer:
|
||||
* its range ring and, unless reduced motion is on or it cannot draw them,
|
||||
* the pulses and their echoes, timed as on Cesium.
|
||||
* The visual through the display: its range ring and, unless reduced
|
||||
* motion is on or the engine cannot draw them, the pulses and their echoes.
|
||||
*/
|
||||
function renderDisplayVisual(key, groundCenter, reducedMotion) {
|
||||
function createVisual(key, groundCenter, reducedMotion) {
|
||||
const display = layerState.display;
|
||||
const animate = Boolean(
|
||||
!reducedMotion &&
|
||||
display.createPulses &&
|
||||
display.onFrameEnd &&
|
||||
display.cameraHeight,
|
||||
);
|
||||
const wake = animate ? newWake() : null;
|
||||
const center = geo.cartesian3.clone(groundCenter);
|
||||
const rim = display.createRim(groundCenter);
|
||||
const disc = reducedMotion
|
||||
? null
|
||||
: (display.createPulses?.(groundCenter) ?? null);
|
||||
// Pulses run from now until `wake.untilMs`, then rest.
|
||||
const wake = disc ? newWake() : null;
|
||||
// From a camera below the rim height the disc would sweep across the sky,
|
||||
// so it is hidden there, and the pulses rest with it: no flash, no
|
||||
// echoes, no frames.
|
||||
const { levelAt, ringAlpha, belowRim } = pulseClock(
|
||||
wake,
|
||||
() => display.cameraHeight() < CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
const center = geo.cartesian3.clone(groundCenter);
|
||||
const rim = display.createRim(groundCenter);
|
||||
const disc = animate ? display.createPulses(groundCenter) : null;
|
||||
const stopPulses = animate
|
||||
const stopPulses = disc
|
||||
? runPulses({
|
||||
center,
|
||||
wake,
|
||||
@@ -807,7 +597,7 @@ export function createRendering({
|
||||
onFrame: display.onFrame,
|
||||
onFrameEnd: display.onFrameEnd,
|
||||
draw({ level, alpha, radii, alphas }) {
|
||||
rim.setAlpha?.(alpha);
|
||||
rim.setAlpha(alpha);
|
||||
disc.draw({ level, radii, alphas });
|
||||
},
|
||||
})
|
||||
@@ -843,181 +633,21 @@ export function createRendering({
|
||||
) {
|
||||
// Move the ring only after a meaningful displacement so live tracking
|
||||
// does not rebuild its geometry for sub-pixel motion every 750 ms.
|
||||
if (
|
||||
geo.cartesian3.distance(layerState.visual.center, groundCenter) >= 250
|
||||
) {
|
||||
if (layerState.visual.rim) {
|
||||
layerState.visual.rim.move(groundCenter);
|
||||
layerState.visual.disc?.move(groundCenter);
|
||||
geo.cartesian3.clone(groundCenter, layerState.visual.center);
|
||||
} else {
|
||||
const positions = awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
// The echo field reads `visual.center` in place and follows on its
|
||||
// own. The pulse disc follows by its model matrix and is rebuilt only
|
||||
// every `gradientRebuildM`: a build costs a few milliseconds.
|
||||
layerState.visual.ringPositions = positions;
|
||||
const { ringPrimitive } = layerState.visual;
|
||||
if (ringPrimitive) {
|
||||
const primitives = layerState.display.viewer.scene.primitives;
|
||||
layerState.visual.ringPrimitive = primitives.add(
|
||||
createRingPrimitive(positions, ringPrimitive.appearance.material),
|
||||
);
|
||||
primitives.remove(ringPrimitive);
|
||||
} else layerState.visual.ring.polyline.positions.setValue(positions);
|
||||
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
|
||||
// A resting disc is hidden, so it catches up when the pulses wake.
|
||||
if (performance.now() < layerState.visual.wake?.untilMs)
|
||||
followPulseDisc();
|
||||
}
|
||||
const visual = layerState.visual;
|
||||
if (geo.cartesian3.distance(visual.center, groundCenter) >= 250) {
|
||||
// The echo field reads `visual.center` in place and follows on its
|
||||
// own. A resting disc is hidden, so it catches up when the pulses wake.
|
||||
visual.rim.move(groundCenter);
|
||||
geo.cartesian3.clone(groundCenter, visual.center);
|
||||
if (performance.now() < visual.wake?.untilMs)
|
||||
visual.disc?.move(groundCenter);
|
||||
// Content actually changed — buy exactly one frame instead of holding.
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearVisual();
|
||||
|
||||
// A Cesium globe draws the ring and the pulse disc as primitives below;
|
||||
// another engine through its display: a ring, and pulses when it can.
|
||||
const viewer = layerState.display.viewer;
|
||||
if (!viewer) {
|
||||
renderDisplayVisual(key, groundCenter, reducedMotion);
|
||||
return;
|
||||
}
|
||||
const entities = [];
|
||||
const scene = viewer.scene;
|
||||
// A viewer without a primitive collection or a preRender hook (headless
|
||||
// tools, partial stubs) gets the static ring only, as the Cyber sonar
|
||||
// scene does. So does a viewer who prefers reduced motion.
|
||||
const animate = Boolean(
|
||||
!reducedMotion &&
|
||||
scene?.primitives?.add &&
|
||||
scene?.preRender?.addEventListener &&
|
||||
scene?.postRender?.addEventListener,
|
||||
);
|
||||
// Pulses run from now until `wake.untilMs`, then rest.
|
||||
const wake = animate ? newWake() : null;
|
||||
// From a camera below the rim height the disc would sweep across the sky,
|
||||
// so it is hidden there, and the pulses rest with it: no flash, no
|
||||
// echoes, no frames.
|
||||
const { levelAt, ringAlpha, belowRim } = pulseClock(
|
||||
wake,
|
||||
() => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
|
||||
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
|
||||
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
|
||||
const ring = AWARENESS_RANGE_RING_STYLE;
|
||||
const core = Cesium.Color.fromCssColorString(ring.color);
|
||||
const halo = Cesium.Color.fromCssColorString(ring.outlineColor);
|
||||
const ringPositions = awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
let ringEntity = null;
|
||||
let ringMaterial = null;
|
||||
let ringPrimitive = null;
|
||||
if (scene?.primitives?.add) {
|
||||
ringMaterial = Cesium.Material.fromType('PolylineOutline', {
|
||||
color: core.clone(),
|
||||
outlineColor: halo.clone(),
|
||||
outlineWidth: ring.outlineWidth,
|
||||
});
|
||||
ringPrimitive =
|
||||
scene.primitives.add(
|
||||
createRingPrimitive(ringPositions, ringMaterial),
|
||||
) ?? null;
|
||||
}
|
||||
if (!ringPrimitive) {
|
||||
// Headless tools and partial stubs have no primitive collection; they
|
||||
// get the static ring as an entity.
|
||||
ringEntity = viewer.entities.add({
|
||||
polyline: {
|
||||
positions: new Cesium.ConstantProperty(ringPositions),
|
||||
width: ring.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
material: new Cesium.PolylineOutlineMaterialProperty({
|
||||
color: core,
|
||||
outlineColor: halo,
|
||||
outlineWidth: ring.outlineWidth,
|
||||
}),
|
||||
},
|
||||
});
|
||||
entities.push(ringEntity);
|
||||
}
|
||||
|
||||
const center = Cesium.Cartesian3.clone(groundCenter);
|
||||
let gradient = null;
|
||||
let stopPulses = null;
|
||||
if (animate) {
|
||||
const pulse = AWARENESS_RANGE_PULSE;
|
||||
const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX);
|
||||
const uniforms = {
|
||||
color: core.clone(),
|
||||
lineColor: core.clone(),
|
||||
haloColor: halo.clone(),
|
||||
radii: new Cesium.Cartesian4(),
|
||||
alphas: new Cesium.Cartesian4(),
|
||||
depth: pulse.gradientDepthFraction,
|
||||
lineWidth: pulse.width,
|
||||
haloWidth: pulse.outlineWidth,
|
||||
};
|
||||
uniforms.color.alpha = pulse.gradientOpacity;
|
||||
// A named type: Cesium caches every material type for the page's
|
||||
// lifetime, so an unnamed fabric (a fresh GUID each time) would leave
|
||||
// one entry per new visual. Built from the cached template, the
|
||||
// uniforms come back as plain copies; restore this visual's own.
|
||||
const material = new Cesium.Material({
|
||||
fabric: {
|
||||
type: AWARENESS_PULSE_MATERIAL_TYPE,
|
||||
uniforms,
|
||||
source: PULSE_GRADIENT_SOURCE,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
Object.assign(material.uniforms, uniforms);
|
||||
gradient = scene.primitives.add(
|
||||
createPulseGradient(groundCenter, material),
|
||||
);
|
||||
stopPulses = runPulses({
|
||||
center,
|
||||
wake,
|
||||
levelAt,
|
||||
ringAlpha,
|
||||
belowRim,
|
||||
onFrame: (run) => scene.preRender.addEventListener(run),
|
||||
onFrameEnd: (run) => scene.postRender.addEventListener(run),
|
||||
draw({ level, alpha, radii, alphas }) {
|
||||
ringMaterial.uniforms.color.alpha = core.alpha * alpha;
|
||||
ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha;
|
||||
// The disc is looked up each frame: a subject move swaps it.
|
||||
const disc = layerState.visual?.gradient;
|
||||
if (disc) disc.show = level > 0;
|
||||
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
|
||||
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
layerState.visual = {
|
||||
key,
|
||||
entities,
|
||||
ring: ringEntity,
|
||||
ringPrimitive,
|
||||
ringPositions,
|
||||
center,
|
||||
gradient,
|
||||
gradientCenter: Cesium.Cartesian3.clone(groundCenter),
|
||||
reducedMotion,
|
||||
wake,
|
||||
stopPulses,
|
||||
removeMotionListener: watchReducedMotion(),
|
||||
};
|
||||
governorRequestRender('awareness-visual');
|
||||
createVisual(key, groundCenter, reducedMotion);
|
||||
}
|
||||
return {
|
||||
ensureDirectionOverlay,
|
||||
|
||||
@@ -4,12 +4,11 @@ import {
|
||||
mapAnalystRecord,
|
||||
parseGeojsonLines,
|
||||
} from '../../sources/infrastructureData.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer,
|
||||
localDatasetError,
|
||||
} from '../../data/localGeojsonCore.js';
|
||||
import { localDatasetError } from '../../data/localGeojsonCore.js';
|
||||
import { createInfrastructureDisplay } from './render/display.js';
|
||||
|
||||
// Split multipart features exactly as the Cesium entity collection does, with stable IDs on both globes.
|
||||
// Split multipart features as Cesium's GeoJsonDataSource names its entities
|
||||
// (a repeated id gets `_2`, `_3`, ...), with stable ids on every globe.
|
||||
function dataset(raw) {
|
||||
const features = [],
|
||||
records = [],
|
||||
@@ -61,11 +60,18 @@ function dataset(raw) {
|
||||
return { features, records };
|
||||
}
|
||||
|
||||
/** The parsed local file and the selected feature outlive any one scene. */
|
||||
/**
|
||||
* One datacenter or dam layer. The parsed local file and the selected
|
||||
* feature outlive any one scene; `createDisplay(engine, config, services)`
|
||||
* draws them on each attached engine (show(dataset), select(featureId),
|
||||
* hide(), destroy(), lod()), by default the contract display
|
||||
* (render/display.js).
|
||||
*/
|
||||
export function createInfrastructureSessionLayer(
|
||||
config,
|
||||
services,
|
||||
nativeFactory,
|
||||
createDisplay = (engine) =>
|
||||
createInfrastructureDisplay(engine, { config, services }),
|
||||
) {
|
||||
const { id, name, icon, source, url } = config;
|
||||
let data = null,
|
||||
@@ -129,27 +135,12 @@ export function createInfrastructureSessionLayer(
|
||||
source,
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
init(viewer, options = {}) {
|
||||
if (viewer || options.engine) return this.attachRenderer(viewer, options);
|
||||
init(_viewer, options = {}) {
|
||||
if (options.engine) return this.attachRenderer(_viewer, options);
|
||||
},
|
||||
attachRenderer(viewer, { engine = null, signal = null } = {}) {
|
||||
attachRenderer(_viewer, { engine = null, signal = null } = {}) {
|
||||
if (display) throw Error('Infrastructure display already attached');
|
||||
if (viewer) {
|
||||
const legacy = createLocalGeoJsonLayer(
|
||||
{ ...config, readFeatures: async () => (await read()).features },
|
||||
services,
|
||||
);
|
||||
display = {
|
||||
show: async () => {
|
||||
await legacy.enable(viewer);
|
||||
error = legacy.getStats().error;
|
||||
},
|
||||
select: (key) => legacy.restoreFeatureSelection(viewer, key),
|
||||
hide: () => legacy.disable(viewer),
|
||||
destroy: () => legacy.destroy(viewer),
|
||||
lod: legacy.getLodDiagnostics,
|
||||
};
|
||||
} else display = nativeFactory(engine, config, services);
|
||||
display = engine ? createDisplay(engine, config, services) : null;
|
||||
// The dataset load belongs to the session and keeps running; only this
|
||||
// scene's wait for it ends when the scene is released. A released
|
||||
// display is destroyed and ignores what arrives later.
|
||||
@@ -168,8 +159,7 @@ export function createInfrastructureSessionLayer(
|
||||
display.destroy();
|
||||
display = null;
|
||||
},
|
||||
enable(viewer) {
|
||||
if (!display && viewer) this.attachRenderer(viewer);
|
||||
enable() {
|
||||
enabled = true;
|
||||
return show();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
// The datacenters and dams on the globe contract (engine.movers,
|
||||
// engine.lines, engine.ground, engine.surface), the same on every engine.
|
||||
// Each point or polygon feature has a reference:
|
||||
// - a 10 px tip disc in the layer colour with a 2 px black outline, drawn
|
||||
// over the scene, about 65 px above the feature's base;
|
||||
// - a 3.5 px stem in the layer colour from the base to the tip, hidden by
|
||||
// the scene;
|
||||
// - for a Point feature, a disc of the 8 px GeoJSON pin's width at the base.
|
||||
// Polygon footprints are one engine.ground polygon set in the layer colour
|
||||
// at 0.3, without an outline (Cesium draws none on a clamped polygon); line
|
||||
// features are one engine.ground corridor set, 2 px in the layer colour.
|
||||
// The camera-height LOD (src/data/localGeojsonLod.js) picks the references
|
||||
// and footprints that draw; it runs again after a camera move, and during
|
||||
// continuous motion on its probe window. Once the camera is within 75 km and
|
||||
// the ground under a reference is in view, the base moves onto the sampled
|
||||
// surface. The references' cards go to the shared world-overlay host. A
|
||||
// click on any of them selects the feature and flies there.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import {
|
||||
applyInfraEvictionGrace,
|
||||
selectInfraLod,
|
||||
shouldRecomputeInfraLod,
|
||||
} from '../../../data/localGeojsonLod.js';
|
||||
import {
|
||||
LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
createLocalInfrastructureOverlayEntry,
|
||||
createLocalInfrastructureOverlayPublisher,
|
||||
labelPriorityFromProperties,
|
||||
localInfrastructureOverlayCopy,
|
||||
selectLocalInfrastructureOverlayCohort,
|
||||
} from '../../../data/localGeojsonCore.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
|
||||
const DEFAULT_LABEL_MAX = 900;
|
||||
const DEFAULT_LABEL_GRID_PX = 132;
|
||||
/** The reference walk runs at most this often. */
|
||||
export const WALK_INTERVAL_MS = 450;
|
||||
const TIP_PX = 10;
|
||||
const TIP_OUTLINE_PX = 2;
|
||||
const STEM_PX = 3.5;
|
||||
// The 8 px GeoJSON pin is about 5 px wide.
|
||||
const PIN_PX = 5;
|
||||
const FOOTPRINT_ALPHA = 0.3;
|
||||
const LINE_PX = 2;
|
||||
// The tip stands this many pixels above the base.
|
||||
const STEM_TARGET_PX = 65;
|
||||
// Before the first walk sizes it.
|
||||
const INITIAL_TIP_M = 2000;
|
||||
const FLY_HEIGHT_M = 5000;
|
||||
const FLY_SECONDS = 1.5;
|
||||
/**
|
||||
* The base starts on the ellipsoid. High features (dams in river canyons)
|
||||
* sit hundreds of metres above it, which buries a short close-in stem, so
|
||||
* once the camera is this near the surface height under each base is
|
||||
* sampled once and the base moves onto it.
|
||||
*/
|
||||
export const GROUND_SAMPLE_MAX_DISTANCE_M = 75000;
|
||||
export const GROUND_SAMPLE_RETRY_MS = 2000;
|
||||
const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
|
||||
/**
|
||||
* A sampleable scene can still never answer under a feature; each retry
|
||||
* asks for the frame the next one needs, so after this many armed retries
|
||||
* without a newly grounded base the layer stops asking. A camera move, a
|
||||
* grounded base or sampling becoming supported again re-opens the budget.
|
||||
* 30 × 2 s is far longer than a tile stream-in.
|
||||
*/
|
||||
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
||||
/** Tip moves below this are camera noise and are not drawn. */
|
||||
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
||||
const TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
||||
|
||||
const finite = (value) => Number.isFinite(value);
|
||||
const lonLat = (position) => [position[0], position[1]];
|
||||
|
||||
/**
|
||||
* Cesium's PerspectiveFrustum.fov: the horizontal angle on a wide canvas,
|
||||
* the vertical one on a tall canvas (what the stem sizing reads).
|
||||
*/
|
||||
function frustumFov({ fovy, aspectRatio }) {
|
||||
const aspect = aspectRatio ?? 1;
|
||||
return aspect > 1 ? 2 * Math.atan(Math.tan(fovy / 2) * aspect) : fovy;
|
||||
}
|
||||
|
||||
/**
|
||||
* The datacenter or dam display: `{ show(dataset), select(featureId),
|
||||
* hide(), destroy(), lod() }`. `dataset` is the session's
|
||||
* `{ features, records }` (src/layers/infrastructure/index.js).
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ config: object, services: object }} options The layer's
|
||||
* descriptor (src/data/infrastructure.js) and the application operations
|
||||
* (docs/INFRASTRUCTURE-LAYERS.md).
|
||||
*/
|
||||
export function createInfrastructureDisplay(
|
||||
engine,
|
||||
{ config, services = {} } = {},
|
||||
) {
|
||||
if (!engine?.movers || !engine?.lines || !engine?.ground)
|
||||
throw new TypeError('The infrastructure display needs a globe engine');
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
source,
|
||||
color,
|
||||
osmDerived = false,
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
} = config;
|
||||
const css = rgbaFromCss(color);
|
||||
const rgba = css.rgba();
|
||||
const footprintFill = [css.red, css.green, css.blue, FOOTPRINT_ALPHA];
|
||||
const publisher = createLocalInfrastructureOverlayPublisher({
|
||||
sourceId: id,
|
||||
host: services.overlayHost,
|
||||
});
|
||||
const key = (featureId) => `${id}:${featureId}`;
|
||||
const wake = (reason = id) => engine.render.request(reason);
|
||||
|
||||
// Records in dataset order (their tip index), by feature id.
|
||||
const rows = new Map();
|
||||
// Line features by id, for clicks.
|
||||
const lineFeatures = new Map();
|
||||
// Drawn object ids (sets and lines) whose picks are this layer's.
|
||||
const owners = new Set();
|
||||
// record id → its stem, while the record is drawn.
|
||||
const stems = new Map();
|
||||
const removers = [];
|
||||
let dataset = null,
|
||||
tips = null,
|
||||
pins = null,
|
||||
footprints = null,
|
||||
corridors = null,
|
||||
exclude = [],
|
||||
destroyed = false;
|
||||
// The LOD selection.
|
||||
let active = new Set(),
|
||||
grace = new Map(),
|
||||
budget = 0,
|
||||
computed = false,
|
||||
topology = null,
|
||||
lastProbeMs = Number.NEGATIVE_INFINITY;
|
||||
const lastLodCamera = geo.cartesian3.create();
|
||||
// The walk.
|
||||
let dirty = true,
|
||||
lastWalk = Number.NEGATIVE_INFINITY,
|
||||
walkCount = 0;
|
||||
// The ground-sample retry.
|
||||
let retryTimer = null,
|
||||
retryArms = 0,
|
||||
lastCapability = null;
|
||||
// Per walk: the view-projection matrix, computed on first use.
|
||||
const viewProjection = geo.matrix4.create();
|
||||
let viewProjectionWalk = -1;
|
||||
const scratchCarto = geo.cartographic.create(0, 0, 0);
|
||||
const scratchLow = geo.cartesian3.create();
|
||||
const scratchHigh = geo.cartesian3.create();
|
||||
const scratchWindow = geo.cartesian2.create(0, 0);
|
||||
|
||||
const photoreal = () => engine.maps?.photorealActive?.() === true;
|
||||
|
||||
function scheduleGroundRetry() {
|
||||
if (retryTimer || !dataset || !engine.surface.supported) return;
|
||||
if (retryArms >= GROUND_SAMPLE_MAX_ARMED_RETRIES) return;
|
||||
retryArms += 1;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (dataset && !destroyed) wake(`local-ground-retry:${id}`);
|
||||
}, GROUND_SAMPLE_RETRY_MS);
|
||||
}
|
||||
|
||||
function clearGroundRetry() {
|
||||
retryArms = 0;
|
||||
lastCapability = null;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
/** The record's vertical at `height` metres above the ellipsoid. */
|
||||
function vertical(row, height, result) {
|
||||
scratchCarto.longitude = row.carto.longitude;
|
||||
scratchCarto.latitude = row.carto.latitude;
|
||||
scratchCarto.height = height;
|
||||
return geo.cartographic.toCartesian(scratchCarto, undefined, result);
|
||||
}
|
||||
|
||||
// Whether both points are outside one side of the view frustum: the
|
||||
// clip-space tests x ≥ -w, x ≤ w, y ≥ -w, y ≤ w and w ≥ 0 (in front of
|
||||
// the camera), each linear in the world position.
|
||||
function bothOutside(low, high) {
|
||||
if (viewProjectionWalk !== walkCount) {
|
||||
engine.projector.viewProjection(viewProjection);
|
||||
viewProjectionWalk = walkCount;
|
||||
}
|
||||
const m = viewProjection;
|
||||
const ax = m[0] * low.x + m[4] * low.y + m[8] * low.z + m[12];
|
||||
const ay = m[1] * low.x + m[5] * low.y + m[9] * low.z + m[13];
|
||||
const aw = m[3] * low.x + m[7] * low.y + m[11] * low.z + m[15];
|
||||
const bx = m[0] * high.x + m[4] * high.y + m[8] * high.z + m[12];
|
||||
const by = m[1] * high.x + m[5] * high.y + m[9] * high.z + m[13];
|
||||
const bw = m[3] * high.x + m[7] * high.y + m[11] * high.z + m[15];
|
||||
return (
|
||||
(ax + aw < 0 && bx + bw < 0) ||
|
||||
(aw - ax < 0 && bw - bx < 0) ||
|
||||
(ay + aw < 0 && by + bw < 0) ||
|
||||
(aw - ay < 0 && bw - by < 0) ||
|
||||
(aw < 0 && bw < 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the camera sees the ground under the record: inside the view
|
||||
* frustum and above the horizon. A sample of unseen ground passes through
|
||||
* the Earth (about -6,344 km near Ashburn on Esri) and fails on every
|
||||
* retry, each one a full offscreen render on Cesium. Unsampled bases sit
|
||||
* on the ellipsoid, so the test uses the terrain height when there is one
|
||||
* (not under photoreal tiles, which hide the globe); otherwise it tests
|
||||
* the vertical across ±GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a
|
||||
* sample can accept.
|
||||
*/
|
||||
function groundInView(row) {
|
||||
const terrain = photoreal()
|
||||
? undefined
|
||||
: engine.surface.terrainHeight(row.carto);
|
||||
const known =
|
||||
finite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||
const low = vertical(
|
||||
row,
|
||||
known ? terrain : -GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
|
||||
scratchLow,
|
||||
);
|
||||
const high = known
|
||||
? low
|
||||
: vertical(row, GROUND_SAMPLE_MAX_ABS_HEIGHT_M, scratchHigh);
|
||||
if (bothOutside(low, high)) return false;
|
||||
// The horizon hides every point below the ellipsoid from a camera near
|
||||
// it, so ground below 0 m is tested at 0 m; unknown ground at the top
|
||||
// of the vertical.
|
||||
const horizonHeight = known
|
||||
? Math.max(terrain, 0)
|
||||
: GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||
return engine.projector.horizonVisible(
|
||||
vertical(row, horizonHeight, scratchHigh),
|
||||
);
|
||||
}
|
||||
|
||||
function setGroundHeight(row, height) {
|
||||
row.groundHeight = height;
|
||||
geo.cartesian3.fromRadians(
|
||||
row.carto.longitude,
|
||||
row.carto.latitude,
|
||||
height,
|
||||
undefined,
|
||||
row.base,
|
||||
);
|
||||
row.baseMoved = true;
|
||||
}
|
||||
|
||||
// The scene height under the record, once, while the visible surface has
|
||||
// loaded. A coarse sample can lie below the loaded terrain: the terrain is
|
||||
// the floor then (not under photoreal tiles), while roofs and valid
|
||||
// below-sea-level heights stay.
|
||||
function sampleGround(row, now) {
|
||||
if (row.groundSampled || !engine.surface.supported) return false;
|
||||
if (now - row.lastSample < GROUND_SAMPLE_RETRY_MS) return false;
|
||||
if (!groundInView(row)) return false;
|
||||
row.lastSample = now;
|
||||
const globe = !photoreal();
|
||||
if (globe && !engine.surface.tilesLoaded()) return false;
|
||||
// The stem stands on the sample point: out of the sample.
|
||||
const stem = stems.get(row.id);
|
||||
stem?.line.show(false);
|
||||
let sampled;
|
||||
try {
|
||||
sampled = engine.surface.sampleHeight(row.carto, exclude);
|
||||
} finally {
|
||||
stem?.line.show(true);
|
||||
}
|
||||
if (!finite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M)
|
||||
return false;
|
||||
const terrain = globe ? engine.surface.terrainHeight(row.carto) : undefined;
|
||||
if (finite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M)
|
||||
sampled = Math.max(sampled, terrain);
|
||||
row.groundSampled = true;
|
||||
setGroundHeight(row, sampled);
|
||||
return true;
|
||||
}
|
||||
|
||||
// A sampled base follows refined terrain upward on the bounded walks,
|
||||
// without another sample.
|
||||
function refreshTerrainFloor(row, camera) {
|
||||
if (!row.groundSampled || photoreal() || !engine.surface.tilesLoaded())
|
||||
return false;
|
||||
if (
|
||||
geo.cartesian3.distance(camera, row.base) >= GROUND_SAMPLE_MAX_DISTANCE_M
|
||||
)
|
||||
return false;
|
||||
const height = engine.surface.terrainHeight(row.carto);
|
||||
if (
|
||||
!finite(height) ||
|
||||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
||||
height <= row.groundHeight
|
||||
)
|
||||
return false;
|
||||
setGroundHeight(row, height);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The tip STEM_TARGET_PX above the base at the camera's distance; a move
|
||||
// under LOCAL_STEM_TIP_EPSILON_M is not drawn.
|
||||
function sizeStem(row, now, pose, view, knownDistance = null) {
|
||||
const distance = finite(knownDistance)
|
||||
? knownDistance
|
||||
: geo.cartesian3.distance(pose.position, row.base);
|
||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleGround(row, now);
|
||||
const tipHeight =
|
||||
row.groundHeight +
|
||||
Math.max(distance, 1) *
|
||||
2 *
|
||||
Math.tan(view.fov / 2) *
|
||||
(STEM_TARGET_PX / view.height);
|
||||
geo.cartesian3.fromRadians(
|
||||
row.carto.longitude,
|
||||
row.carto.latitude,
|
||||
tipHeight,
|
||||
undefined,
|
||||
row.nextTip,
|
||||
);
|
||||
if (geo.cartesian3.distanceSquared(row.tip, row.nextTip) <= TIP_EPSILON_SQ)
|
||||
return false;
|
||||
geo.cartesian3.clone(row.nextTip, row.tip);
|
||||
row.tipMoved = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildFootprints() {
|
||||
const items = [];
|
||||
for (const row of rows.values())
|
||||
if (row.rings && active.has(row.id))
|
||||
items.push({
|
||||
rings: row.rings,
|
||||
fill: footprintFill,
|
||||
pickId: row.pickId,
|
||||
});
|
||||
footprints.replace(items);
|
||||
footprints.commit();
|
||||
}
|
||||
|
||||
function stemOf(row) {
|
||||
let stem = stems.get(row.id);
|
||||
if (!stem) {
|
||||
const lineId = `${id}-stem-${row.id}`;
|
||||
stem = {
|
||||
id: lineId,
|
||||
line: engine.lines.createLine({
|
||||
id: lineId,
|
||||
widthPx: STEM_PX,
|
||||
color: rgba,
|
||||
pickId: row.pickId,
|
||||
}),
|
||||
};
|
||||
stem.line.setPositions([row.base, row.tip]);
|
||||
stem.base = geo.cartesian3.clone(row.base);
|
||||
stem.tip = geo.cartesian3.clone(row.tip);
|
||||
owners.add(lineId);
|
||||
stems.set(row.id, stem);
|
||||
}
|
||||
return stem;
|
||||
}
|
||||
|
||||
function dropStem(rowId) {
|
||||
const stem = stems.get(rowId);
|
||||
if (!stem) return;
|
||||
stem.line.destroy();
|
||||
owners.delete(stem.id);
|
||||
stems.delete(rowId);
|
||||
}
|
||||
|
||||
// The LOD selection, from every record's distance and horizon.
|
||||
function selectLod(pose, now) {
|
||||
const candidates = [];
|
||||
for (const row of rows.values())
|
||||
candidates.push({
|
||||
id: row.id,
|
||||
priority: row.priority,
|
||||
distanceM: geo.cartesian3.distance(pose.position, row.base),
|
||||
inView: engine.projector.horizonVisible(row.base),
|
||||
});
|
||||
const selection = selectInfraLod(candidates, {
|
||||
cameraHeightM: pose.cartographic?.height,
|
||||
incumbentIds: active,
|
||||
});
|
||||
const kept = applyInfraEvictionGrace({
|
||||
selectedIds: selection.activeIds,
|
||||
builtIds: [...active],
|
||||
graceState: grace,
|
||||
nowMs: now,
|
||||
activeLimit: selection.budget.activeLimit,
|
||||
});
|
||||
active = new Set(kept.keepIds);
|
||||
grace = kept.graceState;
|
||||
budget = selection.budget.activeLimit;
|
||||
computed = true;
|
||||
// Travel for the motion probe counts from this selection.
|
||||
geo.cartesian3.clone(pose.position, lastLodCamera);
|
||||
lastProbeMs = now;
|
||||
const next = kept.keepIds.slice().sort().join('\n');
|
||||
if (next !== topology) {
|
||||
topology = next;
|
||||
buildFootprints();
|
||||
}
|
||||
}
|
||||
|
||||
// The reference walk, on WALK_INTERVAL_MS: the LOD after a camera move,
|
||||
// shows, ground samples, tips and stems, and the card cohort.
|
||||
function walk() {
|
||||
if (!dataset) return;
|
||||
const now = performance.now();
|
||||
if (now - lastWalk < WALK_INTERVAL_MS) return;
|
||||
lastWalk = now;
|
||||
walkCount++;
|
||||
const pose = engine.camera.pose();
|
||||
const camera = pose.position;
|
||||
const size = engine.host.size();
|
||||
const view = { fov: frustumFov(pose), height: size.height || 1080 };
|
||||
// Motion without a moveEnd (a follow, Cockpit, a route flight).
|
||||
if (!dirty && computed && rows.size) {
|
||||
const probe = shouldRecomputeInfraLod({
|
||||
nowMs: now,
|
||||
lastProbeMs,
|
||||
movedSqM: geo.cartesian3.distanceSquared(camera, lastLodCamera),
|
||||
cameraHeightM: pose.cartographic?.height,
|
||||
});
|
||||
lastProbeMs = probe.lastProbeMs;
|
||||
if (probe.recompute) dirty = true;
|
||||
}
|
||||
const refresh = dirty;
|
||||
// Without sampling no retry is armed and no per-record work is spent.
|
||||
const canSample = engine.surface.supported === true;
|
||||
// Sampling that comes back re-opens a spent budget on a parked camera.
|
||||
if (canSample && lastCapability === false) retryArms = 0;
|
||||
lastCapability = canSample;
|
||||
let retryPending = false;
|
||||
let grounded = false;
|
||||
if (refresh && rows.size) selectLod(pose, now);
|
||||
let tipsChanged = false;
|
||||
let pinsChanged = false;
|
||||
const visible = [];
|
||||
for (const row of rows.values()) {
|
||||
const isActive = !computed || active.has(row.id);
|
||||
const isVisible = isActive && engine.projector.horizonVisible(row.base);
|
||||
if (row.show !== isVisible) {
|
||||
row.show = isVisible;
|
||||
tips.set(row.index, { show: isVisible });
|
||||
tipsChanged = true;
|
||||
if (row.pinIndex >= 0) {
|
||||
pins.set(row.pinIndex, { show: isVisible });
|
||||
pinsChanged = true;
|
||||
}
|
||||
}
|
||||
if (!isVisible) dropStem(row.id);
|
||||
if (!isActive) continue;
|
||||
const wasSampled = row.groundSampled;
|
||||
const floorChanged = refreshTerrainFloor(row, camera);
|
||||
if (refresh || floorChanged) sizeStem(row, now, pose, view);
|
||||
else if (
|
||||
canSample &&
|
||||
!row.groundSampled &&
|
||||
now - row.lastSample >= GROUND_SAMPLE_RETRY_MS
|
||||
) {
|
||||
const distance = geo.cartesian3.distance(camera, row.base);
|
||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M && sampleGround(row, now))
|
||||
sizeStem(row, now, pose, view, distance);
|
||||
}
|
||||
if (!wasSampled && row.groundSampled) grounded = true;
|
||||
// An ungrounded base in range and in view: the retry needs a frame
|
||||
// the idle governor would never produce.
|
||||
if (
|
||||
canSample &&
|
||||
!row.groundSampled &&
|
||||
!retryPending &&
|
||||
geo.cartesian3.distance(camera, row.base) <
|
||||
GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||
groundInView(row)
|
||||
)
|
||||
retryPending = true;
|
||||
if (row.tipMoved) {
|
||||
row.tipMoved = false;
|
||||
tips.set(row.index, { position: row.tip });
|
||||
tipsChanged = true;
|
||||
}
|
||||
if (row.baseMoved) {
|
||||
row.baseMoved = false;
|
||||
if (row.pinIndex >= 0) {
|
||||
pins.set(row.pinIndex, { position: row.base });
|
||||
pinsChanged = true;
|
||||
}
|
||||
}
|
||||
if (isVisible) {
|
||||
const stem = stems.get(row.id);
|
||||
if (!stem) stemOf(row);
|
||||
else if (
|
||||
!geo.cartesian3.equals(stem.tip, row.tip) ||
|
||||
!geo.cartesian3.equals(stem.base, row.base)
|
||||
) {
|
||||
stem.line.setPositions([row.base, row.tip]);
|
||||
geo.cartesian3.clone(row.base, stem.base);
|
||||
geo.cartesian3.clone(row.tip, stem.tip);
|
||||
}
|
||||
if (row.entry) visible.push(row);
|
||||
}
|
||||
}
|
||||
dirty = false;
|
||||
if (tipsChanged) tips.commit();
|
||||
if (pinsChanged) pins.commit();
|
||||
// Real progress re-opens the budget for the records still waiting.
|
||||
if (grounded) retryArms = 0;
|
||||
if (retryPending) scheduleGroundRetry();
|
||||
publisher.publish(
|
||||
selectLocalInfrastructureOverlayCohort(visible, {
|
||||
maxEntries: labelMax,
|
||||
gridPx: labelGridPx,
|
||||
width: size.width || 0,
|
||||
height: size.height || 0,
|
||||
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
project: (row) =>
|
||||
engine.projector.worldToWindow(row.tip, scratchWindow),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function select(featureId) {
|
||||
const row = rows.get(featureId);
|
||||
if (!row) return false;
|
||||
services.selectEntityContext?.(row);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flyTo(lon, lat) {
|
||||
// Camera input would cancel the flight.
|
||||
engine.input.setCameraInputs(false);
|
||||
const restore = () => engine.input.setCameraInputs(true);
|
||||
engine.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, FLY_HEIGHT_M),
|
||||
duration: FLY_SECONDS,
|
||||
complete: restore,
|
||||
cancel: restore,
|
||||
});
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
const picked = event.picked;
|
||||
if (!dataset || !picked || !owners.has(picked.owner)) return;
|
||||
const featureId = String(picked.id).slice(id.length + 1);
|
||||
const row = rows.get(featureId);
|
||||
if (row) {
|
||||
select(featureId);
|
||||
flyTo(row.lon, row.lat);
|
||||
return;
|
||||
}
|
||||
const feature = lineFeatures.get(featureId);
|
||||
const [lon, lat] = feature?.geometry.coordinates[0] ?? [];
|
||||
if (finite(lon) && finite(lat)) flyTo(lon, lat);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const shown = Boolean(dataset);
|
||||
services.hideOsmCredit?.(engine, id);
|
||||
clearGroundRetry();
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
for (const rowId of [...stems.keys()]) dropStem(rowId);
|
||||
for (const set of [tips, pins, footprints, corridors]) set?.destroy();
|
||||
tips = pins = footprints = corridors = null;
|
||||
exclude = [];
|
||||
owners.clear();
|
||||
if (shown) services.removeEntityContextsForLayer?.(id);
|
||||
rows.clear();
|
||||
lineFeatures.clear();
|
||||
dataset = null;
|
||||
active = new Set();
|
||||
grace = new Map();
|
||||
budget = 0;
|
||||
computed = false;
|
||||
topology = null;
|
||||
lastProbeMs = Number.NEGATIVE_INFINITY;
|
||||
dirty = true;
|
||||
lastWalk = Number.NEGATIVE_INFINITY;
|
||||
publisher.hide();
|
||||
services.clearSelectedEntityContextForLayer?.(id);
|
||||
if (shown) wake(`${id}-hide`);
|
||||
}
|
||||
|
||||
function build(next) {
|
||||
let pinCount = 0;
|
||||
const corridorItems = [];
|
||||
for (const feature of next.features)
|
||||
if (feature.geometry?.type === 'LineString') {
|
||||
lineFeatures.set(feature.id, feature);
|
||||
corridorItems.push({
|
||||
positions: feature.geometry.coordinates.map(lonLat),
|
||||
color: rgba,
|
||||
widthPx: LINE_PX,
|
||||
pickId: key(feature.id),
|
||||
});
|
||||
}
|
||||
for (const input of next.records) {
|
||||
const carto = geo.cartographic.fromDegrees(input.lon, input.lat);
|
||||
const base = geo.cartesian3.fromDegrees(input.lon, input.lat);
|
||||
const tip = geo.cartesian3.fromDegrees(
|
||||
input.lon,
|
||||
input.lat,
|
||||
INITIAL_TIP_M,
|
||||
);
|
||||
const priority = labelPriorityFromProperties(input.properties, id);
|
||||
const geometry = input.geometry;
|
||||
const row = {
|
||||
...input,
|
||||
index: rows.size,
|
||||
pinIndex: geometry?.type === 'Point' ? pinCount++ : -1,
|
||||
rings:
|
||||
geometry?.type === 'Polygon'
|
||||
? geometry.coordinates.map((ring) => ring.map(lonLat))
|
||||
: null,
|
||||
pickId: key(input.id),
|
||||
carto,
|
||||
base,
|
||||
tip,
|
||||
nextTip: geo.cartesian3.clone(tip),
|
||||
priority,
|
||||
groundHeight: 0,
|
||||
groundSampled: false,
|
||||
lastSample: Number.NEGATIVE_INFINITY,
|
||||
// As a Cesium entity's: shown until the first walk decides; the
|
||||
// context store reads it (isContextRecordActive).
|
||||
show: true,
|
||||
tipMoved: false,
|
||||
baseMoved: false,
|
||||
entry: labels
|
||||
? createLocalInfrastructureOverlayEntry({
|
||||
id: input.id,
|
||||
layerId: id,
|
||||
position: tip,
|
||||
properties: input.properties,
|
||||
priority,
|
||||
accent: color,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
rows.set(row.id, row);
|
||||
services.registerEntityContext?.(row, {
|
||||
id: row.pickId,
|
||||
layerId: id,
|
||||
layerName: name,
|
||||
source,
|
||||
label: localInfrastructureOverlayCopy(row.properties, id).title,
|
||||
properties: row.properties,
|
||||
latitude: Number(row.lat.toFixed(6)),
|
||||
longitude: Number(row.lon.toFixed(6)),
|
||||
});
|
||||
}
|
||||
corridors = engine.ground.createSet({
|
||||
id: `${id}-lines`,
|
||||
kind: 'corridor',
|
||||
order: 1,
|
||||
pickable: true,
|
||||
});
|
||||
owners.add(corridors.id);
|
||||
corridors.replace(corridorItems);
|
||||
corridors.commit();
|
||||
footprints = engine.ground.createSet({
|
||||
id: `${id}-footprints`,
|
||||
kind: 'polygon',
|
||||
pickable: true,
|
||||
});
|
||||
owners.add(footprints.id);
|
||||
tips = engine.movers.createSet({
|
||||
id: `${id}-tips`,
|
||||
kind: 'point',
|
||||
capacity: Math.max(1, rows.size),
|
||||
pickable: true,
|
||||
// Never hidden by the scene: the horizon is the walk's.
|
||||
depthTest: 'always',
|
||||
point: {
|
||||
sizePx: TIP_PX,
|
||||
outlinePx: TIP_OUTLINE_PX,
|
||||
outlineColor: [0, 0, 0, 1],
|
||||
},
|
||||
});
|
||||
owners.add(`${id}-tips`);
|
||||
for (const row of rows.values())
|
||||
tips.set(row.index, {
|
||||
position: row.tip,
|
||||
color: rgba,
|
||||
show: true,
|
||||
pickId: row.pickId,
|
||||
});
|
||||
tips.setCount(rows.size);
|
||||
tips.commit();
|
||||
exclude = [tips];
|
||||
if (pinCount) {
|
||||
pins = engine.movers.createSet({
|
||||
id: `${id}-pins`,
|
||||
kind: 'point',
|
||||
capacity: pinCount,
|
||||
pickable: true,
|
||||
point: { sizePx: PIN_PX, outlinePx: 0 },
|
||||
});
|
||||
owners.add(`${id}-pins`);
|
||||
for (const row of rows.values())
|
||||
if (row.pinIndex >= 0)
|
||||
pins.set(row.pinIndex, {
|
||||
position: row.base,
|
||||
color: rgba,
|
||||
show: true,
|
||||
pickId: row.pickId,
|
||||
});
|
||||
pins.setCount(pinCount);
|
||||
pins.commit();
|
||||
exclude.push(pins);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Draws a dataset; the first walk runs on the next frame. */
|
||||
show(next) {
|
||||
if (destroyed || next === dataset) return;
|
||||
if (dataset) hide();
|
||||
try {
|
||||
dataset = next;
|
||||
build(next);
|
||||
removers.push(
|
||||
engine.frames.preRender.addEventListener(walk),
|
||||
engine.camera.moveEnd.addEventListener(() => {
|
||||
dirty = true;
|
||||
lastWalk = Number.NEGATIVE_INFINITY;
|
||||
// A camera move is a fresh situation with free frames.
|
||||
retryArms = 0;
|
||||
wake();
|
||||
}),
|
||||
engine.input.on('click', onClick, { pick: true }),
|
||||
);
|
||||
publisher.show();
|
||||
if (osmDerived && next.features.length)
|
||||
services.showOsmCredit?.(engine, id);
|
||||
wake();
|
||||
} catch (error) {
|
||||
hide();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
select,
|
||||
hide,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
hide();
|
||||
publisher.destroy();
|
||||
destroyed = true;
|
||||
},
|
||||
/**
|
||||
* Globe-LOD state for QA (scripts/qa-infra-lod.mjs) and tests: `total`
|
||||
* records, `active` the ones the LOD keeps, `budgetLimit` the last
|
||||
* camera-height band's cap, `computed` false until the first walk.
|
||||
*/
|
||||
lod: () => ({
|
||||
total: rows.size,
|
||||
active: active.size,
|
||||
budgetLimit: budget,
|
||||
computed,
|
||||
}),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,358 +0,0 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import {
|
||||
selectInfraLod,
|
||||
applyInfraEvictionGrace,
|
||||
INFRA_LOD_ACTIVE_MAX,
|
||||
} from '../../../data/localGeojsonLod.js';
|
||||
import {
|
||||
createLocalInfrastructureOverlayEntry,
|
||||
selectLocalInfrastructureOverlayCohort,
|
||||
createLocalInfrastructureOverlayPublisher,
|
||||
LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
labelPriorityFromProperties,
|
||||
localInfrastructureOverlayCopy,
|
||||
} from '../../../data/localGeojsonCore.js';
|
||||
import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js';
|
||||
import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js';
|
||||
import { distanceToSegment } from '../../../globe/cpuPicks.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import { createStemBatch } from '../../shared/render/navaraStems.js';
|
||||
|
||||
export function createInfrastructureNavaraRenderer(
|
||||
engine,
|
||||
native,
|
||||
config,
|
||||
services,
|
||||
) {
|
||||
const { id, name, source, color, labelMax, labelGridPx } = config;
|
||||
const rgba = rgbaFromCss(color).rgba();
|
||||
const publisher = createLocalInfrastructureOverlayPublisher({
|
||||
sourceId: id,
|
||||
host: services.overlayHost,
|
||||
});
|
||||
const display = createNavaraPolygonDisplay(engine, native.view, (tint) => ({
|
||||
polyline: { color: tint, width: 2, clampToGround: true },
|
||||
polygon: {
|
||||
color: tint,
|
||||
opacity: 0.3,
|
||||
transparent: true,
|
||||
clampToGround: true,
|
||||
outline: true,
|
||||
outlineColor: tint,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
}));
|
||||
let dataset = null,
|
||||
records = new Map(),
|
||||
active = new Set(),
|
||||
grace = new Map(),
|
||||
shown = [],
|
||||
budget = 0;
|
||||
let lastWalk = -Infinity,
|
||||
signature = '',
|
||||
topology = '',
|
||||
points = null,
|
||||
stems = null,
|
||||
unbindShapes = null;
|
||||
let retryTimer = null;
|
||||
const removers = [];
|
||||
const wake = () => engine.render.request(id);
|
||||
const key = (fid) => id + ':' + fid;
|
||||
function footprints() {
|
||||
const features = [],
|
||||
shapes = [];
|
||||
for (const feature of dataset.features) {
|
||||
const g = feature.geometry;
|
||||
if (
|
||||
g.type !== 'LineString' &&
|
||||
(g.type !== 'Polygon' || !active.has(feature.id))
|
||||
)
|
||||
continue;
|
||||
features.push(feature);
|
||||
shapes.push({
|
||||
id: key(feature.id),
|
||||
geometry:
|
||||
g.type === 'LineString'
|
||||
? { type: 'line', positions: g.coordinates }
|
||||
: {
|
||||
type: 'polygon',
|
||||
outer: g.coordinates[0],
|
||||
holes: g.coordinates.slice(1),
|
||||
},
|
||||
style: { lineWidth: 2 },
|
||||
});
|
||||
}
|
||||
display.replace(features.length ? new Map([[color, features]]) : new Map());
|
||||
unbindShapes?.();
|
||||
unbindShapes = bindGroundShapePicks(engine, id, shapes);
|
||||
}
|
||||
function sweep(force = false) {
|
||||
if (!dataset) return;
|
||||
const now = performance.now();
|
||||
if (!force && now - lastWalk < 450) return;
|
||||
lastWalk = now;
|
||||
const pose = engine.camera.pose(),
|
||||
size = engine.host.size();
|
||||
const sig = [
|
||||
pose.position.x,
|
||||
pose.position.y,
|
||||
pose.position.z,
|
||||
pose.heading,
|
||||
pose.pitch,
|
||||
pose.roll,
|
||||
pose.fovy,
|
||||
size.width,
|
||||
size.height,
|
||||
].join('/');
|
||||
const sampling = shown.some(
|
||||
(r) =>
|
||||
!r.grounded &&
|
||||
r.tries < 30 &&
|
||||
now - r.lastSample >= 2000 &&
|
||||
r.distance < 75000,
|
||||
);
|
||||
if (!force && signature === sig && !sampling) return;
|
||||
if (force || signature !== sig) {
|
||||
const candidates = [];
|
||||
for (const r of records.values()) {
|
||||
r.distance = geo.cartesian3.distance(pose.position, r.base);
|
||||
candidates.push({
|
||||
id: r.id,
|
||||
priority: r.priority,
|
||||
distanceM: r.distance,
|
||||
inView: engine.projector.horizonVisible(r.base),
|
||||
});
|
||||
}
|
||||
const result = selectInfraLod(candidates, {
|
||||
cameraHeightM: pose.cartographic.height,
|
||||
incumbentIds: active,
|
||||
});
|
||||
budget = result.budget.activeLimit;
|
||||
const next = applyInfraEvictionGrace({
|
||||
selectedIds: result.activeIds,
|
||||
builtIds: [...active],
|
||||
graceState: grace,
|
||||
nowMs: now,
|
||||
activeLimit: budget,
|
||||
});
|
||||
active = new Set(next.keepIds);
|
||||
grace = next.graceState;
|
||||
signature = sig;
|
||||
const nextTopology = next.keepIds.slice().sort().join('/');
|
||||
if (force || topology !== nextTopology) {
|
||||
topology = nextTopology;
|
||||
footprints();
|
||||
}
|
||||
}
|
||||
shown = [];
|
||||
for (const fid of active) {
|
||||
const r = records.get(fid);
|
||||
if (!engine.projector.horizonVisible(r.base)) continue;
|
||||
const carto = r.carto;
|
||||
if (
|
||||
r.distance < 75000 &&
|
||||
now - r.lastSample >= 2000 &&
|
||||
(force || r.tries < 30)
|
||||
) {
|
||||
const terrain = engine.surface.terrainHeight(carto);
|
||||
const sampled = engine.surface.supported
|
||||
? engine.surface.sampleHeight(carto, [points])
|
||||
: undefined;
|
||||
const floor = Number.isFinite(sampled)
|
||||
? Math.max(sampled, terrain ?? -Infinity)
|
||||
: terrain;
|
||||
r.lastSample = now;
|
||||
r.tries++;
|
||||
if (Number.isFinite(floor) && Math.abs(floor) <= 9000) {
|
||||
r.floor = floor;
|
||||
r.grounded = Number.isFinite(sampled) || !engine.surface.supported;
|
||||
geo.cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
floor,
|
||||
undefined,
|
||||
r.base,
|
||||
);
|
||||
}
|
||||
}
|
||||
const height =
|
||||
r.floor +
|
||||
(Math.max(1, r.distance) * 2 * Math.tan(pose.fovy / 2) * 65) /
|
||||
Math.max(1, size.height);
|
||||
geo.cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
height,
|
||||
undefined,
|
||||
r.tip,
|
||||
);
|
||||
points.set(shown.length, {
|
||||
position: r.tip,
|
||||
color: rgba,
|
||||
show: true,
|
||||
pickId: key(fid),
|
||||
});
|
||||
shown.push(r);
|
||||
}
|
||||
points.setCount(shown.length);
|
||||
points.commit();
|
||||
stems.set(shown, pose.position);
|
||||
publisher.publish(
|
||||
selectLocalInfrastructureOverlayCohort(shown, {
|
||||
maxEntries: labelMax,
|
||||
gridPx: labelGridPx,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
project: (r) => engine.projector.worldToWindow(r.tip, {}),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
!retryTimer &&
|
||||
engine.surface.supported &&
|
||||
shown.some((r) => !r.grounded && r.distance < 75000 && r.tries < 30)
|
||||
) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
wake();
|
||||
}, 2050);
|
||||
}
|
||||
wake();
|
||||
}
|
||||
function select(fid) {
|
||||
const row = records.get(fid);
|
||||
if (!row) return false;
|
||||
services.selectEntityContext(row);
|
||||
return true;
|
||||
}
|
||||
function hide() {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
while (removers.length) removers.pop()();
|
||||
unbindShapes?.();
|
||||
unbindShapes = null;
|
||||
publisher.hide();
|
||||
display.destroy();
|
||||
points?.destroy();
|
||||
stems?.destroy();
|
||||
points = stems = dataset = null;
|
||||
records.clear();
|
||||
active.clear();
|
||||
grace.clear();
|
||||
shown = [];
|
||||
budget = 0;
|
||||
signature = topology = '';
|
||||
services.removeEntityContextsForLayer(id);
|
||||
services.hideOsmCredit(engine, id);
|
||||
}
|
||||
return {
|
||||
show(next) {
|
||||
if (dataset === next) return;
|
||||
dataset = next;
|
||||
points = engine.movers.createSet({
|
||||
id,
|
||||
kind: 'point',
|
||||
capacity: INFRA_LOD_ACTIVE_MAX,
|
||||
pickable: true,
|
||||
depthTest: 'always',
|
||||
point: { sizePx: 10, outlinePx: 2, outlineColor: [0, 0, 0, 1] },
|
||||
});
|
||||
stems = createStemBatch(native, color, INFRA_LOD_ACTIVE_MAX);
|
||||
for (const input of next.records) {
|
||||
const carto = geo.cartographic.fromDegrees(input.lon, input.lat);
|
||||
const base = geo.cartesian3.fromDegrees(input.lon, input.lat),
|
||||
tip = geo.cartesian3.clone(base);
|
||||
const priority = labelPriorityFromProperties(input.properties, id);
|
||||
const row = {
|
||||
...input,
|
||||
carto,
|
||||
base,
|
||||
tip,
|
||||
priority,
|
||||
floor: 0,
|
||||
tries: 0,
|
||||
lastSample: -Infinity,
|
||||
grounded: false,
|
||||
};
|
||||
row.entry = createLocalInfrastructureOverlayEntry({
|
||||
id: row.id,
|
||||
layerId: id,
|
||||
position: tip,
|
||||
properties: row.properties,
|
||||
priority,
|
||||
accent: color,
|
||||
});
|
||||
records.set(row.id, row);
|
||||
services.registerEntityContext(row, {
|
||||
id: key(row.id),
|
||||
layerId: id,
|
||||
layerName: name,
|
||||
source,
|
||||
label: localInfrastructureOverlayCopy(row.properties, id).title,
|
||||
properties: row.properties,
|
||||
latitude: row.lat,
|
||||
longitude: row.lon,
|
||||
});
|
||||
}
|
||||
display.setVisible(true);
|
||||
publisher.show();
|
||||
if (next.features.length) services.showOsmCredit(engine, id);
|
||||
removers.push(
|
||||
engine.frames.preRender.addEventListener(() => sweep()),
|
||||
engine.camera.moveEnd.addEventListener(() => sweep(true)),
|
||||
);
|
||||
removers.push(
|
||||
engine.picker.registerMeshPicker((at) => {
|
||||
const hits = [];
|
||||
for (const row of shown) {
|
||||
const a = engine.projector.worldToWindow(row.base, {}),
|
||||
b = engine.projector.worldToWindow(row.tip, {});
|
||||
if (a && b && distanceToSegment(at, a, b) <= 2.5)
|
||||
hits.push({
|
||||
onTop: 0,
|
||||
order: 0,
|
||||
depth: row.distance,
|
||||
result: { id: key(row.id), owner: id },
|
||||
});
|
||||
}
|
||||
return hits;
|
||||
}),
|
||||
);
|
||||
removers.push(
|
||||
engine.input.on(
|
||||
'click',
|
||||
(event) => {
|
||||
if (!isPointerFree() || event.picked?.owner !== id) return;
|
||||
const fid = String(event.picked.id).slice(id.length + 1);
|
||||
const row = records.get(fid);
|
||||
const feature = row || next.features.find((f) => f.id === fid);
|
||||
if (!feature) return;
|
||||
if (row) select(fid);
|
||||
const [lon, lat] = row
|
||||
? [row.lon, row.lat]
|
||||
: feature.geometry.coordinates[0];
|
||||
engine.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, 5000),
|
||||
duration: 1.5,
|
||||
});
|
||||
},
|
||||
{ pick: true },
|
||||
),
|
||||
);
|
||||
sweep(true);
|
||||
},
|
||||
select,
|
||||
hide,
|
||||
destroy() {
|
||||
hide();
|
||||
publisher.destroy();
|
||||
},
|
||||
lod: () => ({
|
||||
total: records.size,
|
||||
active: active.size,
|
||||
budgetLimit: budget,
|
||||
computed: Boolean(dataset),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInfrastructureSessionLayer } from './index.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { localGeoJsonServices } from '../../app/localGeojsonServices.js';
|
||||
import {
|
||||
getContextStore,
|
||||
selectEntityContext,
|
||||
} from '../../data/contextStore.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
test('a bundled file delivered between planets keeps multipart IDs and is read only once', async (t) => {
|
||||
let deliver,
|
||||
@@ -106,3 +113,278 @@ test('a released scene stops waiting for a stalled dataset load, which stays in
|
||||
assert.equal(captures.length, 0);
|
||||
assert.equal(layer.getAnalystRecords().length, 1);
|
||||
});
|
||||
|
||||
// The session layer with the contract display on the fake engine and the
|
||||
// application's context store: dataset failures, analyst records, and
|
||||
// the load, toggle and teardown lifecycle.
|
||||
|
||||
const dam = JSON.stringify({
|
||||
type: 'Feature',
|
||||
id: 'dam',
|
||||
properties: {
|
||||
name: 'Runtime Dam',
|
||||
output: '12 MW',
|
||||
tags: { associated_river: 'Test River', operator: 'Test Hydro' },
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-97.7, 30.2],
|
||||
[-97.69, 30.2],
|
||||
[-97.69, 30.21],
|
||||
[-97.7, 30.2],
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
const serve = (text = dam) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => text,
|
||||
});
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
function contractHarness(t) {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
const engine = createFakeEngine();
|
||||
const create = () =>
|
||||
createInfrastructureSessionLayer(
|
||||
{
|
||||
id: 'local-dams',
|
||||
name: 'Dams',
|
||||
color: '#0088ff',
|
||||
url: '/dams.geojsonl',
|
||||
osmDerived: true,
|
||||
},
|
||||
{
|
||||
...localGeoJsonServices,
|
||||
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
|
||||
},
|
||||
);
|
||||
const layer = create();
|
||||
layer.init(null, { engine });
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
engine.destroy();
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
return {
|
||||
engine,
|
||||
layer,
|
||||
create,
|
||||
contexts: () => getContextStore().entities.size,
|
||||
drawn: () =>
|
||||
engine.movers.list().length +
|
||||
engine.lines.list().length +
|
||||
engine.ground.list().length,
|
||||
listeners: () => engine.fake.callsOf('input.on').length,
|
||||
};
|
||||
}
|
||||
|
||||
test('a missing dataset reports UNAVAILABLE instead of a silent empty layer', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '<!DOCTYPE html>',
|
||||
}));
|
||||
await env.layer.enable();
|
||||
const stats = env.layer.getStats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.lastUpdate, null);
|
||||
assert.equal(stats.error, 'dataset unavailable (HTTP 404)');
|
||||
assert.equal(layerFeedState(stats), 'unavailable');
|
||||
assert.equal(env.drawn(), 0);
|
||||
});
|
||||
|
||||
test('a corrupt dataset line reports malformed rather than parsing into nothing', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => serve('{"type":"Feature"\n'));
|
||||
await env.layer.enable();
|
||||
const stats = env.layer.getStats();
|
||||
assert.equal(stats.error, 'dataset is malformed');
|
||||
assert.equal(layerFeedState(stats), 'unavailable');
|
||||
});
|
||||
|
||||
test('a failed load is retried by the next enable and then clears its error', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
let fail = true;
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
fail ? { ok: false, status: 500, text: async () => '' } : serve(),
|
||||
);
|
||||
await env.layer.enable();
|
||||
assert.equal(env.layer.getStats().error, 'dataset unavailable (HTTP 500)');
|
||||
fail = false;
|
||||
env.layer.disable();
|
||||
await env.layer.enable();
|
||||
const stats = env.layer.getStats();
|
||||
assert.equal(stats.error, null);
|
||||
assert.equal(stats.count, 1);
|
||||
assert.ok(Number.isFinite(stats.lastUpdate));
|
||||
assert.equal(layerFeedState(stats), 'nominal');
|
||||
assert.equal(env.engine.ground.list().length, 2, 'one draw, not two');
|
||||
});
|
||||
|
||||
test('a loaded dataset is distinguishable from a dead one', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => serve());
|
||||
await env.layer.enable();
|
||||
const stats = env.layer.getStats();
|
||||
assert.equal(stats.error, null);
|
||||
assert.equal(stats.count, 1);
|
||||
assert.ok(Number.isFinite(stats.lastUpdate), 'a load timestamps itself');
|
||||
assert.equal(layerFeedState(stats), 'nominal');
|
||||
});
|
||||
|
||||
test('analyst records snapshot the enabled layer; disable and destroy return []', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => serve());
|
||||
assert.deepEqual(env.layer.getAnalystRecords(), []);
|
||||
await env.layer.enable();
|
||||
const rows = env.layer.getAnalystRecords();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].id, 'Runtime Dam');
|
||||
assert.equal(rows[0].name, 'Runtime Dam');
|
||||
assert.equal(rows[0].operator, 'Test Hydro');
|
||||
assert.equal(rows[0].river, 'Test River');
|
||||
assert.equal(rows[0].output, '12 MW');
|
||||
assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon));
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]);
|
||||
assert.equal(env.layer.getAnalystRecords(0).length, 1, 'at least one');
|
||||
env.layer.disable();
|
||||
assert.deepEqual(env.layer.getAnalystRecords(), []);
|
||||
await env.layer.enable();
|
||||
env.layer.destroy();
|
||||
assert.deepEqual(env.layer.getAnalystRecords(), []);
|
||||
});
|
||||
|
||||
test('destroy removes context records and permits a fresh replacement', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => serve());
|
||||
await env.layer.enable();
|
||||
const oldEntity = [...getContextStore().entities.values()][0].entity;
|
||||
assert.equal(env.contexts(), 1);
|
||||
env.layer.disable();
|
||||
assert.equal(env.contexts(), 0, 'disable releases its records');
|
||||
assert.equal(env.drawn(), 0, 'and what it drew');
|
||||
env.layer.destroy();
|
||||
assert.equal(env.contexts(), 0);
|
||||
assert.equal(env.drawn(), 0);
|
||||
const replacement = env.create();
|
||||
replacement.init(null, { engine: env.engine });
|
||||
await replacement.enable();
|
||||
assert.equal(env.contexts(), 1);
|
||||
assert.notEqual(
|
||||
[...getContextStore().entities.values()][0].entity,
|
||||
oldEntity,
|
||||
);
|
||||
replacement.destroy();
|
||||
await env.layer.enable();
|
||||
assert.equal(env.contexts(), 0, 'a destroyed layer stays inert');
|
||||
assert.equal(env.drawn(), 0);
|
||||
});
|
||||
|
||||
for (const phase of ['fetch', 'text']) {
|
||||
test(`destroy during ${phase} leaves no late objects or context records`, async (t) => {
|
||||
const env = contractHarness(t);
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const pause = async (value) => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return value;
|
||||
};
|
||||
let signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
signal = options.signal;
|
||||
if (phase === 'fetch') return pause(serve());
|
||||
return { ok: true, text: () => pause(dam) };
|
||||
});
|
||||
const loading = env.layer.enable();
|
||||
await entered.promise;
|
||||
env.layer.destroy();
|
||||
assert.equal(signal.aborted, true);
|
||||
release.resolve();
|
||||
await loading;
|
||||
assert.equal(env.drawn(), 0);
|
||||
assert.equal(env.contexts(), 0);
|
||||
assert.deepEqual(env.layer.getStats(), {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('concurrent enable and disable/re-enable share one pending dataset load', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
const release = deferred();
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
await release.promise;
|
||||
return serve();
|
||||
});
|
||||
const first = env.layer.enable();
|
||||
env.layer.disable();
|
||||
const second = env.layer.enable();
|
||||
release.resolve();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
assert.equal(env.engine.ground.list().length, 2, 'drawn once');
|
||||
assert.equal(env.contexts(), 1);
|
||||
});
|
||||
|
||||
test('disable during loading draws nothing; re-enable draws without refetching', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
const release = deferred();
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
await release.promise;
|
||||
return serve();
|
||||
});
|
||||
const loading = env.layer.enable();
|
||||
env.layer.disable();
|
||||
release.resolve();
|
||||
await loading;
|
||||
assert.equal(env.drawn(), 0, 'a load that ends after disable draws nothing');
|
||||
assert.equal(env.contexts(), 0);
|
||||
await env.layer.enable();
|
||||
assert.ok(env.drawn() > 0);
|
||||
assert.equal(fetchMock.mock.callCount(), 1, 'the dataset is kept');
|
||||
});
|
||||
|
||||
test('a toggle-off frees the drawing and a toggle-on rebuilds it from the kept dataset', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => serve());
|
||||
await env.layer.enable();
|
||||
assert.equal(env.layer.getLodDiagnostics().total, 1);
|
||||
env.layer.disable();
|
||||
assert.equal(env.drawn(), 0, 'nothing stays drawn while the layer is off');
|
||||
assert.equal(env.contexts(), 0);
|
||||
assert.equal(env.layer.getLodDiagnostics().total, 0);
|
||||
assert.equal(env.layer.getStats().count, 1, 'the row keeps the dataset size');
|
||||
await env.layer.enable();
|
||||
assert.ok(env.drawn() > 0);
|
||||
assert.equal(env.contexts(), 1);
|
||||
assert.equal(fetchMock.mock.callCount(), 1, 'no second fetch');
|
||||
assert.equal(env.layer.getLodDiagnostics().total, 1);
|
||||
});
|
||||
|
||||
test('a scene switch restores the selected feature on the next display', async (t) => {
|
||||
const env = contractHarness(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => serve());
|
||||
await env.layer.enable();
|
||||
const record = [...getContextStore().entities.values()][0];
|
||||
selectEntityContext(record.entity);
|
||||
env.layer.detachRenderer();
|
||||
assert.equal(getContextStore().selectedEntityId, null);
|
||||
await env.layer.attachRenderer(null, { engine: env.engine });
|
||||
assert.equal(getContextStore().selectedEntityId, 'local-dams:dam');
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createLifecycle } from './render/cesium/lifecycle.js';
|
||||
import { LAYER_ID } from './policy.js';
|
||||
import { OUTLINE_SET_ID } from './render/display.js';
|
||||
import { createDisplayHarness } from './render/displayHarness.mjs';
|
||||
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
|
||||
|
||||
for (const source of [
|
||||
{ name: 'OpenStreetMap', id: 'tile:base' },
|
||||
@@ -11,46 +13,20 @@ for (const source of [
|
||||
null,
|
||||
]) {
|
||||
test(`enable restores cached installation credits before a refresh: ${source?.id || 'empty'}`, () => {
|
||||
const visibleCredits = new Set();
|
||||
const noop = () => {};
|
||||
const viewer = {
|
||||
camera: {},
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => visibleCredits.add(credit),
|
||||
removeStaticCredit: (credit) => visibleCredits.delete(credit),
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
viewer,
|
||||
records: source ? [{ id: 'base', sources: [source] }] : [],
|
||||
recordById: new Map(),
|
||||
dataSource: { show: false },
|
||||
};
|
||||
let markersVisible = false;
|
||||
const parts = {
|
||||
namedMarkers: {
|
||||
enable: () => {
|
||||
markersVisible = true;
|
||||
},
|
||||
sync: noop,
|
||||
hide: () => {
|
||||
markersVisible = false;
|
||||
},
|
||||
},
|
||||
rendering: {},
|
||||
viewport: { clearUnavailableRetry: noop },
|
||||
ingestion: { setInstallationStatus: noop },
|
||||
};
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
credits,
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
context: { clearSelectedEntityContextForLayer: noop },
|
||||
},
|
||||
parts,
|
||||
source: {},
|
||||
}).methods;
|
||||
const h = createDisplayHarness(createFakeGroundHarness);
|
||||
h.services.credits = credits;
|
||||
const { engine } = h;
|
||||
const visibleCredits = () => engine.fake.credits.length;
|
||||
h.setRecords(
|
||||
source
|
||||
? [{ id: 'base', latitude: 0, longitude: 0, sources: [source] }]
|
||||
: [],
|
||||
);
|
||||
h.state.recordById = new Map();
|
||||
const lifecycle = h.parts.lifecycle.methods;
|
||||
lifecycle.init();
|
||||
const outlinesShown = () =>
|
||||
[0, 1].every((n) => h.set(`${OUTLINE_SET_ID}-${n}`).shown);
|
||||
const expected =
|
||||
source?.name === 'OpenStreetMap'
|
||||
? source.id.startsWith('tile:')
|
||||
@@ -60,23 +36,24 @@ for (const source of [
|
||||
try {
|
||||
for (let cycle = 0; cycle < 2; cycle++) {
|
||||
lifecycle.enable();
|
||||
assert.equal(state.dataSource.show, true);
|
||||
assert.equal(markersVisible, true);
|
||||
assert.equal(outlinesShown(), true);
|
||||
assert.equal(h.labels.visible, true);
|
||||
// No renderRecords or successful fetch occurs between toggles.
|
||||
assert.equal(visibleCredits.size, expected);
|
||||
assert.equal(visibleCredits(), expected);
|
||||
lifecycle.disable();
|
||||
assert.equal(state.dataSource.show, false);
|
||||
assert.equal(markersVisible, false);
|
||||
assert.equal(visibleCredits.size, 0);
|
||||
assert.equal(outlinesShown(), false);
|
||||
assert.equal(h.labels.visible, false);
|
||||
assert.equal(visibleCredits(), 0);
|
||||
}
|
||||
credits.showOsmCredit(viewer, 'another-layer');
|
||||
credits.showOsmCredit(engine, 'another-layer');
|
||||
lifecycle.enable();
|
||||
lifecycle.disable();
|
||||
assert.equal(visibleCredits.size, 1, 'another owner retains its credit');
|
||||
assert.equal(credits.hideOsmCredit(viewer, LAYER_ID), false);
|
||||
assert.equal(visibleCredits(), 1, 'another owner retains its credit');
|
||||
assert.equal(credits.hideOsmCredit(engine, LAYER_ID), false);
|
||||
} finally {
|
||||
lifecycle.disable();
|
||||
credits.hideOsmCredit(viewer, 'another-layer');
|
||||
credits.hideOsmCredit(engine, 'another-layer');
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import { createNamedMarkers } from './render/cesium/namedMarkers.js';
|
||||
import { createLifecycle } from './render/cesium/lifecycle.js';
|
||||
import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js';
|
||||
import { createDisplayHarness } from './render/displayHarness.mjs';
|
||||
import { GROUND_HARNESSES } from '../../testSupport/groundDisplayEngines.mjs';
|
||||
|
||||
// The named-area markers on the fake engine: its camera events, frames and
|
||||
// window projection (scripted per test) drive the discs.
|
||||
function setup(t) {
|
||||
let labels = [], options, publications = 0, visible = false;
|
||||
const overlayHost = {
|
||||
@@ -17,25 +18,38 @@ function setup(t) {
|
||||
};
|
||||
let x = -100;
|
||||
const projections = new Set();
|
||||
t.mock.method(C.SceneTransforms, 'worldToWindowCoordinates', (_scene, _point, scratch) => { projections.add(scratch); scratch.x = x; scratch.y = 300; return scratch; });
|
||||
const camera = { changed: new C.Event(), moveEnd: new C.Event(), positionWC: C.Cartesian3.fromDegrees(0, 0, 1e6), viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) };
|
||||
const state = { viewer: { camera, dataSources: { add() {}, remove() {} }, scene: { canvas: { clientWidth: 1280, clientHeight: 800 }, preRender: new C.Event(), primitives: { add: (p) => p, remove() {} } } }, enabled: true, contextAnchor: { latitude: 0, longitude: 0 } };
|
||||
const services = { render: { governorRequestRender() {} }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, context: { clearSelectedEntityContextForLayer() {} } };
|
||||
let loads = 0;
|
||||
const parts = { model: { colorFor: () => C.Color.ORANGE }, rendering: { installationSurfaceHeightM: () => 0, clearRendered() {} }, viewport: { scheduleLoad: () => loads++, clearUnavailableRetry() {} }, selection: { installInteraction() {} }, ingestion: { setInstallationStatus() {} } };
|
||||
const ctx = { state, services, parts, source: {}, overlayHost };
|
||||
parts.namedMarkers = createNamedMarkers(ctx);
|
||||
const lifecycle = createLifecycle(ctx).methods;
|
||||
lifecycle.init(state.viewer); lifecycle.enable();
|
||||
const h = createDisplayHarness(GROUND_HARNESSES[0], { overlayHost });
|
||||
const { engine } = h;
|
||||
engine.fake.script('worldToWindow', (_point, scratch) => { projections.add(scratch); scratch.x = x; scratch.y = 300; return scratch; });
|
||||
engine.fake.setSize(1280, 800);
|
||||
const base = {
|
||||
changed: engine.camera.changed.numberOfListeners,
|
||||
moveEnd: engine.camera.moveEnd.numberOfListeners,
|
||||
preRender: engine.frames.preRender.numberOfListeners,
|
||||
};
|
||||
h.state.contextAnchor = { latitude: 0, longitude: 0 };
|
||||
h.start();
|
||||
let height = 1e7;
|
||||
const records = [1, 2].map((n) => ({ id: `osm:military:w${n}`, name: `Site ${n}`, namedArea: true, areaM2: 100 / n, latitude: 0, longitude: 0, pointOnly: true }));
|
||||
return { state, camera, get labels() { return labels; }, get options() { return options; }, get visible() { return visible; }, projections, records, markers: parts.namedMarkers, lifecycle, setX: (value) => { x = value; }, publications: () => publications, loads: () => loads };
|
||||
t.after(() => h.destroy());
|
||||
return {
|
||||
state: h.state, engine, base,
|
||||
get labels() { return labels; }, get options() { return options; }, get visible() { return visible; },
|
||||
projections, records, markers: h.parts.namedMarkers, lifecycle: h.parts.lifecycle.methods,
|
||||
setX: (value) => { x = value; },
|
||||
/** A pose change too small for a camera event. */
|
||||
nudge() { height += 1; engine.fake.setPose({ height }); },
|
||||
frame: () => engine.fake.emit('frames.preRender', 0),
|
||||
cameraChanged() { engine.fake.emit('camera.changed'); engine.fake.emit('camera.moveEnd'); },
|
||||
publications: () => publications, loads: h.loads,
|
||||
};
|
||||
}
|
||||
|
||||
test('Contacts camera motion reprojects and declutters existing labels without source loads or primitive replacement', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync(h.records);
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 0);
|
||||
h.setX(600); h.camera.changed.raiseEvent(); h.camera.moveEnd.raiseEvent();
|
||||
h.setX(600); h.cameraChanged();
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 2);
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.options.collisionCapacity, 24);
|
||||
@@ -44,22 +58,21 @@ test('Contacts camera motion reprojects and declutters existing labels without s
|
||||
assert.equal(h.loads(), 0);
|
||||
assert.equal(h.publications(), 3);
|
||||
assert.equal(h.projections.size, 1);
|
||||
h.setX(1400); h.camera.viewMatrix[12] = 1; h.state.viewer.scene.preRender.raiseEvent();
|
||||
h.setX(1400); h.nudge(); h.frame();
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 0, 'small pose changes refresh before render');
|
||||
h.lifecycle.disable();
|
||||
assert.equal(h.camera.changed.numberOfListeners, 1, 'only the viewport listener remains');
|
||||
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(h.engine.camera.changed.numberOfListeners, h.base.changed + 1, 'only the viewport listener remains');
|
||||
assert.equal(h.engine.frames.preRender.numberOfListeners, h.base.preRender);
|
||||
assert.equal(h.labels.length, 0);
|
||||
assert.equal(h.visible, false);
|
||||
h.state.records = h.records;
|
||||
h.setX(600); h.lifecycle.enable();
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.publications(), 5);
|
||||
const oldScene = h.state.viewer.scene;
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
assert.equal(h.camera.changed.numberOfListeners, 0);
|
||||
assert.equal(h.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(oldScene.preRender.numberOfListeners, 0);
|
||||
h.lifecycle.destroy();
|
||||
assert.equal(h.engine.camera.changed.numberOfListeners, h.base.changed);
|
||||
assert.equal(h.engine.camera.moveEnd.numberOfListeners, h.base.moveEnd);
|
||||
assert.equal(h.engine.frames.preRender.numberOfListeners, h.base.preRender);
|
||||
});
|
||||
|
||||
test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => {
|
||||
@@ -81,7 +94,6 @@ test('wide named markers retain primitives until close polygons publish with the
|
||||
assert.equal(first.title, 'Site 1');
|
||||
assert.equal(h.labels.length, 2, 'no duplicate title at handoff');
|
||||
assert.equal(retainNamedHandoff(h.records, [], { west: 1, east: 2, south: 1, north: 2 }).length, 0);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
test('canonical aliases transfer named marker ownership without replacing primitives', (t) => {
|
||||
@@ -108,7 +120,6 @@ test('canonical aliases transfer named marker ownership without replacing primit
|
||||
});
|
||||
}
|
||||
assert.equal(h.labels.length, 1);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
|
||||
@@ -125,13 +136,13 @@ test('the bounded host cohort ranks wide points and polygon titles by area', (t)
|
||||
const first = h.labels[0];
|
||||
const publications = h.publications();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
h.camera.viewMatrix[12] += 0.01;
|
||||
h.state.viewer.scene.preRender.raiseEvent();
|
||||
h.nudge();
|
||||
h.frame();
|
||||
}
|
||||
assert.equal(h.labels[0], first);
|
||||
assert.equal(h.publications(), publications, 'camera frames never rebuild host entries');
|
||||
assert.equal(h.projections.size, 1, 'point projection reuses scratch storage');
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
h.lifecycle.destroy();
|
||||
assert.equal(h.labels.length, 0);
|
||||
});
|
||||
|
||||
@@ -146,7 +157,6 @@ test('wide and polygon titles use the Data Centers entry contract and persistent
|
||||
const reference = createLocalInfrastructureOverlayEntry({ id: entry.id, layerId: 'military-installations', position: entry.position, properties: { name: entry.title }, priority: entry.priority, accent: entry.accent });
|
||||
assert.deepEqual(entry, { ...reference, selected: false });
|
||||
}
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
|
||||
@@ -167,7 +177,6 @@ test('selection promotes exactly one entry and deselection restores that same la
|
||||
assert.equal(original.variant, 'card');
|
||||
assert.equal(original.selected, false);
|
||||
assert.deepEqual(original.details, []);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
test('Contacts can select a point outside the normal title cap or an unnamed polygon', (t) => {
|
||||
@@ -182,7 +191,6 @@ test('Contacts can select a point outside the normal title cap or an unnamed pol
|
||||
assert.equal(h.labels.filter(e => e.selected).length, 1);
|
||||
assert.equal(h.labels.length, 512);
|
||||
}
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
test('selected markers without an explicit namedArea flag stay hidden safely', (t) => {
|
||||
@@ -196,5 +204,4 @@ test('selected markers without an explicit namedArea flag stay hidden safely', (
|
||||
h.state.selectedId = record.id;
|
||||
assert.doesNotThrow(() => h.markers.sync([record]));
|
||||
h.markers.visit((_id, point) => assert.equal(point.show, false));
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
@@ -2,49 +2,50 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createLifecycle } from './render/cesium/lifecycle.js';
|
||||
import { createDisplayHarness } from './render/displayHarness.mjs';
|
||||
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
|
||||
|
||||
test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and recover from wide views', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
|
||||
const camera = {
|
||||
changed: new Cesium.Event(),
|
||||
moveEnd: new Cesium.Event(),
|
||||
percentageChanged: 0.5,
|
||||
computeViewRectangle: () => rectangle,
|
||||
const h = createDisplayHarness(createFakeGroundHarness);
|
||||
const { engine, state, parts } = h;
|
||||
engine.fake.script('viewRectangle', () => rectangle);
|
||||
const camera = engine.camera;
|
||||
const base = {
|
||||
changed: camera.changed.numberOfListeners,
|
||||
moveEnd: camera.moveEnd.numberOfListeners,
|
||||
sensitivity: camera.percentageChanged,
|
||||
};
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
};
|
||||
const state = { enabled: false, renderedKeys: new Map() };
|
||||
const changed = () => engine.fake.emit('camera.changed');
|
||||
const moveEnd = () => engine.fake.emit('camera.moveEnd');
|
||||
const loads = [];
|
||||
const parts = {
|
||||
selection: { installInteraction() {} },
|
||||
rendering: { clearRendered() {} },
|
||||
ingestion: {
|
||||
loadInstallations() {
|
||||
state.abort = new AbortController();
|
||||
loads.push({
|
||||
...parts.viewport.loadArea(viewer),
|
||||
signal: state.abort.signal,
|
||||
});
|
||||
},
|
||||
setInstallationStatus() {},
|
||||
parts.ingestion = {
|
||||
loadInstallations() {
|
||||
state.abort = new AbortController();
|
||||
loads.push({
|
||||
...parts.viewport.loadArea(null),
|
||||
signal: state.abort.signal,
|
||||
});
|
||||
},
|
||||
setInstallationStatus() {},
|
||||
};
|
||||
const services = {
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: { clearSelectedEntityContextForLayer() {} },
|
||||
};
|
||||
const ctx = { state, parts, services, source: {} };
|
||||
parts.viewport = createViewport(ctx);
|
||||
const life = createLifecycle(ctx).methods;
|
||||
life.init(viewer);
|
||||
parts.viewport = createViewport({
|
||||
state,
|
||||
parts,
|
||||
services: h.services,
|
||||
source: {},
|
||||
});
|
||||
const life = parts.lifecycle.methods;
|
||||
life.init();
|
||||
life.enable();
|
||||
assert.equal(
|
||||
camera.percentageChanged,
|
||||
0.01,
|
||||
'the layer claims a fine camera',
|
||||
);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
camera.changed.raiseEvent();
|
||||
changed();
|
||||
t.mock.timers.tick(15);
|
||||
}
|
||||
t.mock.timers.tick(200);
|
||||
@@ -53,7 +54,7 @@ test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and
|
||||
1,
|
||||
'changed alone must load, even without moveEnd',
|
||||
);
|
||||
camera.moveEnd.raiseEvent();
|
||||
moveEnd();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, 1);
|
||||
assert.equal(
|
||||
@@ -62,12 +63,12 @@ test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and
|
||||
'duplicate settle must not abort its own load',
|
||||
);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
|
||||
camera.changed.raiseEvent();
|
||||
changed();
|
||||
assert.equal(loads[0].signal.aborted, true);
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, 2);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-150, 10, -80, 60);
|
||||
camera.moveEnd.raiseEvent();
|
||||
moveEnd();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(
|
||||
loads.at(-1).box.west,
|
||||
@@ -75,25 +76,25 @@ test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and
|
||||
'wide views reach the named-point source',
|
||||
);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
|
||||
camera.changed.raiseEvent();
|
||||
changed();
|
||||
t.mock.timers.tick(200);
|
||||
assert.ok(loads.at(-1).box);
|
||||
state.contextAnchor = { latitude: 32.8, longitude: -117.1 };
|
||||
const count = loads.length;
|
||||
camera.moveEnd.raiseEvent();
|
||||
moveEnd();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, count, 'Contacts owns the anchor while active');
|
||||
life.disable();
|
||||
assert.equal(state.contextAnchor, null);
|
||||
life.enable();
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
|
||||
camera.changed.raiseEvent();
|
||||
changed();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.at(-1).coverage.kind, 'viewport');
|
||||
life.destroy(viewer);
|
||||
assert.equal(camera.changed.numberOfListeners, 0);
|
||||
assert.equal(camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(camera.percentageChanged, 0.5);
|
||||
h.destroy();
|
||||
assert.equal(camera.changed.numberOfListeners, base.changed);
|
||||
assert.equal(camera.moveEnd.numberOfListeners, base.moveEnd);
|
||||
assert.equal(camera.percentageChanged, base.sensitivity);
|
||||
});
|
||||
|
||||
test('an aborting real ingestion cannot clear the next scheduled view key', async (t) => {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createLifecycle } from './cesium/lifecycle.js';
|
||||
import { createNamedMarkers } from './cesium/namedMarkers.js';
|
||||
import { createRendering } from './cesium/rendering.js';
|
||||
import { createSelection } from './cesium/selection.js';
|
||||
|
||||
/** Compose the existing Cesium-owned scene, input and entity adapters. */
|
||||
export function createInstallationsCesiumRenderer(context) {
|
||||
return {
|
||||
lifecycle: createLifecycle(context),
|
||||
namedMarkers: createNamedMarkers(context),
|
||||
rendering: createRendering(context),
|
||||
selection: createSelection(context),
|
||||
};
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { LAYER_ID } from '../../policy.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../../../data/cameraSensitivity.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
const { clearSelectedEntityContextForLayer } = services.context;
|
||||
const cameraOwner = (viewer) => layerState.engine ?? viewer?.camera;
|
||||
|
||||
const methods = {
|
||||
init(viewer, { engine = null } = {}) {
|
||||
layerState.viewer = viewer;
|
||||
layerState.engine = engine;
|
||||
layerState.dataSource = new Cesium.CustomDataSource(
|
||||
'military-installations',
|
||||
);
|
||||
const added = viewer.dataSources.add(layerState.dataSource);
|
||||
const camera = engine?.camera ?? viewer.camera;
|
||||
const cameraChanged = () => {
|
||||
if (!layerState.contextAnchor) parts.viewport.scheduleLoad();
|
||||
};
|
||||
layerState.moveEndRemove = camera.moveEnd.addEventListener(cameraChanged);
|
||||
layerState.changedRemove =
|
||||
camera.changed?.addEventListener(cameraChanged);
|
||||
parts.selection.installInteraction(viewer);
|
||||
return added;
|
||||
},
|
||||
|
||||
enable() {
|
||||
layerState.enabled = true;
|
||||
layerState.surfaceChangedRemove ||= services.maps?.subscribeMapStack(
|
||||
parts.rendering.syncSelectionFill,
|
||||
);
|
||||
// Cached markers and geometry become visible before the next fetch.
|
||||
if (
|
||||
layerState.records?.some((r) =>
|
||||
r.sources?.some((s) => s.name === 'OpenStreetMap'),
|
||||
)
|
||||
)
|
||||
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
|
||||
openMapTiles: layerState.records.some((r) =>
|
||||
r.sources?.some(
|
||||
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
|
||||
),
|
||||
),
|
||||
});
|
||||
parts.namedMarkers?.enable();
|
||||
parts.namedMarkers?.sync(layerState.records || []);
|
||||
claimCameraSensitivity(cameraOwner(layerState.viewer), LAYER_ID, 0.01);
|
||||
registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
|
||||
layerState.dataSource.show = true;
|
||||
// DataLayerManager invokes update() immediately after enable(), which owns
|
||||
// the first fetch. Avoid racing it with a second aborting request here.
|
||||
},
|
||||
|
||||
disable() {
|
||||
layerState.enabled = false;
|
||||
services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
|
||||
services.credits?.hideGooglePlacesCredit?.(layerState.viewer, LAYER_ID);
|
||||
parts.namedMarkers?.hide();
|
||||
releaseCameraSensitivity(cameraOwner(layerState.viewer), LAYER_ID);
|
||||
layerState.contextAnchor = null;
|
||||
layerState.contextPosition = null;
|
||||
layerState.coverage = { kind: 'viewport' };
|
||||
layerState.cameraLoadKey = null;
|
||||
layerState.cameraLoadOwner = null;
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
clearTimeout(layerState.timer);
|
||||
layerState.abort?.abort();
|
||||
layerState.abort = null;
|
||||
layerState.loading = false;
|
||||
if (layerState.dataSource) layerState.dataSource.show = false;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
layerState.selectedId = null;
|
||||
parts.rendering.clearSelectionFill?.();
|
||||
layerState.surfaceChangedRemove?.();
|
||||
layerState.surfaceChangedRemove = null;
|
||||
layerState.failureReason = null;
|
||||
parts.ingestion.setInstallationStatus('idle');
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
this.detach(viewer);
|
||||
},
|
||||
|
||||
detach(viewer) {
|
||||
services.credits?.hideOsmCredit?.(viewer, LAYER_ID);
|
||||
services.credits?.hideGooglePlacesCredit?.(viewer, LAYER_ID);
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
releaseCameraSensitivity(cameraOwner(viewer), LAYER_ID);
|
||||
layerState.surfaceChangedRemove?.();
|
||||
layerState.surfaceChangedRemove = null;
|
||||
layerState.moveEndRemove?.();
|
||||
layerState.changedRemove?.();
|
||||
layerState.moveEndRemove = layerState.changedRemove = null;
|
||||
parts.selection.destroy?.();
|
||||
layerState.clickHandler?.destroy();
|
||||
layerState.clickHandler = null;
|
||||
parts.namedMarkers?.destroy();
|
||||
parts.rendering.clearRendered();
|
||||
if (layerState.dataSource && viewer)
|
||||
viewer.dataSources.remove(layerState.dataSource, true);
|
||||
layerState.dataSource = null;
|
||||
layerState.viewer = null;
|
||||
layerState.engine = null;
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { installationClassLabel, installationTitle } from '../../classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
MILITARY_POINT_CAP,
|
||||
} from '../../../../data/militaryNames.js';
|
||||
import { LAYER_ID, COLOR_BY_CLASS } from '../../policy.js';
|
||||
import { createInfrastructureOverlayEntry } from '../../../../data/infrastructureOverlayEntry.js';
|
||||
|
||||
/** Bounded point markers; the shared overlay owns text projection and decluttering. */
|
||||
export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
let points = null;
|
||||
let entries = [];
|
||||
const markers = new Map();
|
||||
const screen = new Cesium.Cartesian2();
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
|
||||
let shownPoints = 0;
|
||||
let ordered = [];
|
||||
const viewMatrix = new Cesium.Matrix4();
|
||||
let width = 0,
|
||||
height = 0;
|
||||
let removeChanged = null,
|
||||
removeEnd = null,
|
||||
removeFrame = null;
|
||||
|
||||
function refresh() {
|
||||
const viewer = state.viewer;
|
||||
if (!state.enabled || !points) return;
|
||||
width = viewer.scene.canvas.clientWidth;
|
||||
height = viewer.scene.canvas.clientHeight;
|
||||
if (viewer.camera.viewMatrix)
|
||||
Cesium.Matrix4.clone(viewer.camera.viewMatrix, viewMatrix);
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
shownPoints = 0;
|
||||
for (let index = 0; index < ordered.length; index++) {
|
||||
const marker = ordered[index];
|
||||
const point = marker.point;
|
||||
point.show = Boolean(
|
||||
marker.named && occluder.isPointVisible(point.position),
|
||||
);
|
||||
if (!point.show) continue;
|
||||
const projected = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
viewer.scene,
|
||||
point.position,
|
||||
screen,
|
||||
);
|
||||
if (
|
||||
projected &&
|
||||
screen.x >= 0 &&
|
||||
screen.y >= 0 &&
|
||||
screen.x < width &&
|
||||
screen.y < height
|
||||
)
|
||||
shownPoints++;
|
||||
}
|
||||
}
|
||||
|
||||
function publishLabels() {
|
||||
overlayHost.setEntries(LAYER_ID, entries, {
|
||||
// Keep the bounded point cohort available while Contacts owns the window.
|
||||
// The arbiter spends at most 24 slots on currently visible, separated titles.
|
||||
cohortLimit: MILITARY_POINT_CAP,
|
||||
collisionCapacity: MILITARY_LABEL_CAP,
|
||||
maxVisible: MILITARY_LABEL_CAP - (state.selectedId ? 1 : 0),
|
||||
moving: false,
|
||||
visible: state.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function cameraChanged() {
|
||||
refresh();
|
||||
services.render.governorRequestRender('installation-names');
|
||||
}
|
||||
function frame() {
|
||||
const viewer = state.viewer;
|
||||
if (
|
||||
viewer?.camera.viewMatrix &&
|
||||
(!Cesium.Matrix4.equals(viewMatrix, viewer.camera.viewMatrix) ||
|
||||
width !== viewer.scene.canvas.clientWidth ||
|
||||
height !== viewer.scene.canvas.clientHeight)
|
||||
)
|
||||
refresh();
|
||||
}
|
||||
function enable() {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer || removeChanged || removeEnd || removeFrame) return;
|
||||
removeChanged = viewer.camera.changed?.addEventListener(cameraChanged);
|
||||
removeEnd = viewer.camera.moveEnd?.addEventListener(cameraChanged);
|
||||
removeFrame = viewer.scene.preRender?.addEventListener(frame);
|
||||
if (points) points.show = true;
|
||||
publishLabels();
|
||||
overlayHost.setVisible(LAYER_ID, true);
|
||||
cameraChanged();
|
||||
}
|
||||
function hide() {
|
||||
removeChanged?.();
|
||||
removeEnd?.();
|
||||
removeFrame?.();
|
||||
removeChanged = removeEnd = removeFrame = null;
|
||||
if (points) points.show = false;
|
||||
overlayHost.clearSource(LAYER_ID);
|
||||
overlayHost.setVisible(LAYER_ID, false);
|
||||
shownPoints = 0;
|
||||
}
|
||||
|
||||
function sync(records) {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer?.scene?.primitives) return;
|
||||
if (!points) {
|
||||
points = viewer.scene.primitives.add(
|
||||
new Cesium.PointPrimitiveCollection(),
|
||||
);
|
||||
}
|
||||
points.show = state.enabled;
|
||||
const named = records
|
||||
.filter((r) => r.namedArea || r.id === state.selectedId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.id === state.selectedId) -
|
||||
Number(a.id === state.selectedId) || b.areaM2 - a.areaM2,
|
||||
)
|
||||
.slice(0, MILITARY_POINT_CAP);
|
||||
const keep = new Set(named.map((r) => r.id));
|
||||
for (const record of named) {
|
||||
if (markers.has(record.id)) continue;
|
||||
const alias = record.aliasIds?.find(
|
||||
(id) => !keep.has(id) && markers.has(id),
|
||||
);
|
||||
if (!alias) continue;
|
||||
const marker = markers.get(alias);
|
||||
markers.delete(alias);
|
||||
marker.point.id = { installationId: record.id };
|
||||
marker.entry.id = record.id;
|
||||
markers.set(record.id, marker);
|
||||
}
|
||||
for (const [id, marker] of markers)
|
||||
if (!keep.has(id)) {
|
||||
points.remove(marker.point);
|
||||
markers.delete(id);
|
||||
}
|
||||
ordered = [];
|
||||
entries = [];
|
||||
for (const record of named) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
record.pointOnly
|
||||
? 0
|
||||
: parts.rendering.installationSurfaceHeightM(record),
|
||||
);
|
||||
let marker = markers.get(record.id);
|
||||
if (!marker) {
|
||||
const id = { installationId: record.id };
|
||||
marker = {
|
||||
point: points.add({
|
||||
id,
|
||||
position,
|
||||
pixelSize: 7,
|
||||
color: parts.model.colorFor(record),
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: Infinity,
|
||||
}),
|
||||
entry: createInfrastructureOverlayEntry({
|
||||
id: record.id,
|
||||
source: LAYER_ID,
|
||||
position,
|
||||
title: installationTitle(record.name),
|
||||
priority: record.areaM2 || 0,
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
}),
|
||||
};
|
||||
markers.set(record.id, marker);
|
||||
} else {
|
||||
marker.point.position = position;
|
||||
}
|
||||
marker.named = record.namedArea;
|
||||
marker.entry.variant =
|
||||
record.id === state.selectedId ? 'selected' : 'card';
|
||||
marker.entry.selected = record.id === state.selectedId;
|
||||
marker.entry.details = marker.entry.selected
|
||||
? [
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
]
|
||||
: [];
|
||||
marker.entry.position = position;
|
||||
marker.entry.title = installationTitle(record.name);
|
||||
marker.entry.priority = record.areaM2 || 0;
|
||||
entries.push(marker.entry);
|
||||
ordered.push(marker);
|
||||
}
|
||||
if (state.enabled) publishLabels();
|
||||
refresh();
|
||||
services.render.governorRequestRender('installation-names');
|
||||
}
|
||||
return {
|
||||
sync,
|
||||
visit(visitor) {
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const marker = ordered[i];
|
||||
visitor(marker.point.id.installationId, marker.point);
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
enable,
|
||||
hide,
|
||||
stats: () => ({
|
||||
namedMarkers: markers.size,
|
||||
pointsOnScreen: shownPoints,
|
||||
labelsOnScreen: state.enabled
|
||||
? overlayHost.getDiagnostics?.().paintedBySource[LAYER_ID] || 0
|
||||
: 0,
|
||||
labelCap: MILITARY_LABEL_CAP,
|
||||
}),
|
||||
destroy() {
|
||||
hide();
|
||||
ordered = [];
|
||||
if (points) state.viewer?.scene.primitives.remove(points);
|
||||
points = null;
|
||||
entries = [];
|
||||
markers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
import { installationClassLabel, installationTitle } from '../../classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from '../../policy.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { floorAltitudeM, cachedGroundFloor } = services.ground;
|
||||
const {
|
||||
removeEntityContextsForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
const { governorRequestRender } = services.render;
|
||||
const { warmFireAnchorFloors } = services.anchors;
|
||||
|
||||
/**
|
||||
* Shared rendered-surface height for an installation anchor or footprint.
|
||||
* @param {{latitude:number, longitude:number}} record Installation record.
|
||||
* @returns {number} Ellipsoidal render height in metres.
|
||||
*/
|
||||
|
||||
function installationSurfaceHeightM(record) {
|
||||
return (
|
||||
floorAltitudeM(
|
||||
null,
|
||||
cachedGroundFloor(record?.latitude, record?.longitude),
|
||||
) ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
let selectedFillKey = null;
|
||||
const selectedFillIds = [];
|
||||
|
||||
function clearSelectionFill() {
|
||||
for (const id of selectedFillIds)
|
||||
layerState.dataSource?.entities.removeById(id);
|
||||
selectedFillIds.length = 0;
|
||||
selectedFillKey = null;
|
||||
}
|
||||
|
||||
/** Ground geometry classifies the active surface; no height/extrusion plane. */
|
||||
function syncSelectionFill() {
|
||||
const record =
|
||||
layerState.enabled && layerState.recordById.get(layerState.selectedId);
|
||||
if (!record) {
|
||||
clearSelectionFill();
|
||||
return;
|
||||
}
|
||||
const classificationType =
|
||||
layerState.viewer.scene.globe?.show === false
|
||||
? Cesium.ClassificationType.CESIUM_3D_TILE
|
||||
: Cesium.ClassificationType.BOTH;
|
||||
const key = `${record.id}|${renderKey(record)}|${classificationType}`;
|
||||
if (key === selectedFillKey) return;
|
||||
clearSelectionFill();
|
||||
selectedFillKey = key;
|
||||
const footprints =
|
||||
record.footprints || (record.footprint ? [[record.footprint]] : []);
|
||||
const material = new Cesium.ColorMaterialProperty(
|
||||
parts.model.colorFor(record).withAlpha(0.22),
|
||||
);
|
||||
for (let i = 0; i < footprints.length; i++) {
|
||||
const rings = footprints[i];
|
||||
const positions = (ring) =>
|
||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||
const fill = layerState.dataSource.entities.add({
|
||||
id: `${record.id}:selected-fill:${i}`,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
positions(rings[0]),
|
||||
rings
|
||||
.slice(1)
|
||||
.map((ring) => new Cesium.PolygonHierarchy(positions(ring))),
|
||||
),
|
||||
material,
|
||||
classificationType,
|
||||
},
|
||||
});
|
||||
fill.installationId = record.id;
|
||||
selectedFillIds.push(fill.id);
|
||||
}
|
||||
governorRequestRender('installation-selection-fill');
|
||||
}
|
||||
|
||||
function clearRendered() {
|
||||
clearSelectionFill();
|
||||
if (layerState.dataSource?.entities)
|
||||
layerState.dataSource.entities.removeAll();
|
||||
layerState.renderedKeys.clear();
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry identity of one rendered record. Records whose key is unchanged
|
||||
* keep their Cesium entities across refreshes: ground-clamped outlines and
|
||||
* classification fills rebuild asynchronously, so recreating them on every
|
||||
* reload made unchanged sites blink.
|
||||
* @param {object} record Installation record.
|
||||
* @returns {string} Stable key for the record's drawn geometry.
|
||||
*/
|
||||
function renderKey(record) {
|
||||
const footprints =
|
||||
record.footprints || (record.footprint ? [[record.footprint]] : []);
|
||||
const outlines =
|
||||
record.outlineLines || (record.footprint ? [record.footprint] : []);
|
||||
let points = 0;
|
||||
for (const rings of footprints)
|
||||
for (const ring of rings) points += ring.length;
|
||||
for (const line of outlines) points += line.length;
|
||||
return [
|
||||
record.latitude.toFixed(6),
|
||||
record.longitude.toFixed(6),
|
||||
record.class,
|
||||
record.kind,
|
||||
record.name,
|
||||
footprints.length,
|
||||
outlines.length,
|
||||
points,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function removeRecordEntities(id) {
|
||||
const entities = layerState.dataSource.entities;
|
||||
for (const entityId of layerState.renderedKeys.get(id)?.entityIds || [])
|
||||
entities.removeById(entityId);
|
||||
layerState.renderedKeys.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
|
||||
* the selected one when it falls outside that window.
|
||||
*
|
||||
* Context navigation walks the FULL nearby cohort, which is not bounded by the
|
||||
* render cap, so selecting item 701+ used to produce no entity at all — the
|
||||
* camera flew, `getById` returned null, and the selection was silently dropped
|
||||
* on the floor, leaving the Context subject stale so NEXT offered the same
|
||||
* installation forever. One extra entity keeps every cohort item selectable
|
||||
* and the cohort count honest.
|
||||
* @returns {Array<object>} Records to render this paint.
|
||||
*/
|
||||
|
||||
function renderableRecords() {
|
||||
const rendered = layerState.records
|
||||
.filter((r) => !r.pointOnly)
|
||||
.slice(0, MAX_RENDERED);
|
||||
if (!layerState.selectedId) return rendered;
|
||||
if (rendered.some((record) => record.id === layerState.selectedId))
|
||||
return rendered;
|
||||
const selected = layerState.recordById.get(layerState.selectedId);
|
||||
return selected ? [...rendered, selected] : rendered;
|
||||
}
|
||||
|
||||
function syncPlacesCredit(records) {
|
||||
const usesPlaces = records.some((record) =>
|
||||
record.sources?.some((source) => source.name === 'Google Maps Places'),
|
||||
);
|
||||
if (usesPlaces)
|
||||
services.credits?.showGooglePlacesCredit?.(layerState.viewer, LAYER_ID);
|
||||
else
|
||||
services.credits?.hideGooglePlacesCredit?.(layerState.viewer, LAYER_ID);
|
||||
}
|
||||
|
||||
function renderRecords({ claimSelection = false } = {}) {
|
||||
// Context navigation can select another layer without a canvas click.
|
||||
// A delayed floor/data repaint must not steal that newer selection back.
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
if (
|
||||
!claimSelection &&
|
||||
layerState.selectedId &&
|
||||
(!selectedContext || selectedContext.id !== layerState.selectedId)
|
||||
) {
|
||||
layerState.selectedId = null;
|
||||
}
|
||||
let migratedSelection = false;
|
||||
if (
|
||||
layerState.selectedId &&
|
||||
!layerState.recordById.has(layerState.selectedId)
|
||||
) {
|
||||
const replacement = layerState.records.find((record) =>
|
||||
record.aliasIds?.includes(layerState.selectedId),
|
||||
);
|
||||
if (replacement) {
|
||||
layerState.selectedId = replacement.id;
|
||||
migratedSelection = true;
|
||||
}
|
||||
}
|
||||
// Post-moveEnd debounced fetches commit after the camera settles; the
|
||||
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
|
||||
governorRequestRender('installations-render');
|
||||
if (
|
||||
layerState.records.some((r) =>
|
||||
r.sources?.some((s) => s.name === 'OpenStreetMap'),
|
||||
)
|
||||
)
|
||||
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
|
||||
openMapTiles: layerState.records.some((r) =>
|
||||
r.sources?.some(
|
||||
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
|
||||
),
|
||||
),
|
||||
});
|
||||
else services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
|
||||
syncPlacesCredit(layerState.records);
|
||||
const records = renderableRecords();
|
||||
const keys = new Map(
|
||||
records.map((record) => [record.id, renderKey(record)]),
|
||||
);
|
||||
const entities = layerState.dataSource.entities;
|
||||
entities.suspendEvents();
|
||||
for (const [id, rendered] of [...layerState.renderedKeys])
|
||||
if (keys.get(id) !== rendered.key) removeRecordEntities(id);
|
||||
for (const record of records) {
|
||||
const color = parts.model.colorFor(record);
|
||||
const surfaceHeightM = installationSurfaceHeightM(record);
|
||||
const displayPosition = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
surfaceHeightM,
|
||||
);
|
||||
const selectedRecord = record.id === layerState.selectedId;
|
||||
const retained = layerState.renderedKeys.has(record.id)
|
||||
? entities.getById(record.id)
|
||||
: null;
|
||||
if (retained) {
|
||||
// Same geometry: update the marker in place (selection, floor height).
|
||||
if (retained.gevInstallationHeightM !== surfaceHeightM) {
|
||||
retained.gevInstallationHeightM = surfaceHeightM;
|
||||
retained.position = displayPosition;
|
||||
retained.gevDisplayPosition = () => displayPosition;
|
||||
}
|
||||
if (retained.gevInstallationSelected !== selectedRecord) {
|
||||
retained.gevInstallationSelected = selectedRecord;
|
||||
retained.point.pixelSize = selectedRecord ? 13 : 9;
|
||||
retained.point.color = selectedRecord ? Cesium.Color.WHITE : color;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const entityIds = [record.id];
|
||||
layerState.renderedKeys.set(record.id, {
|
||||
key: keys.get(record.id),
|
||||
entityIds,
|
||||
});
|
||||
const entity = layerState.dataSource.entities.add({
|
||||
id: record.id,
|
||||
position: displayPosition,
|
||||
point: {
|
||||
show: !record.namedArea,
|
||||
pixelSize: record.id === layerState.selectedId ? 13 : 9,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
color:
|
||||
record.id === layerState.selectedId ? Cesium.Color.WHITE : color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
const outlines =
|
||||
record.outlineLines || (record.footprint ? [record.footprint] : []);
|
||||
for (let i = 0; i < outlines.length; i++) {
|
||||
const outline = layerState.dataSource.entities.add({
|
||||
id: `${record.id}:outline:${i}`,
|
||||
polyline: {
|
||||
positions: outlines[i].map(([lon, lat]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
),
|
||||
width: 2,
|
||||
material: new Cesium.ColorMaterialProperty(color.withAlpha(0.85)),
|
||||
clampToGround: true,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
outline.installationId = record.id;
|
||||
entityIds.push(outline.id);
|
||||
}
|
||||
entity.gevInstallationHeightM = surfaceHeightM;
|
||||
entity.gevInstallationSelected = selectedRecord;
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
],
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
};
|
||||
registerEntityContext(entity, {
|
||||
id: record.id,
|
||||
layerId: LAYER_ID,
|
||||
layerName:
|
||||
record.kind === 'place_candidate'
|
||||
? 'Military Site Search Candidates'
|
||||
: 'Mapped Military Installations',
|
||||
source: parts.model.installationSourceLabel(record),
|
||||
label: record.name,
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
class: record.class,
|
||||
memberNames: record.memberNames || [],
|
||||
primaryType: record.primaryType || null,
|
||||
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
|
||||
validation: record.validation,
|
||||
retrievedAt: record.retrievedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
entities.resumeEvents();
|
||||
const selectedEntity = layerState.selectedId
|
||||
? layerState.dataSource.entities.getById(layerState.selectedId)
|
||||
: null;
|
||||
if (selectedEntity && (claimSelection || migratedSelection))
|
||||
selectEntityContext(selectedEntity);
|
||||
else if (!selectedEntity) layerState.selectedId = null;
|
||||
// Re-register rebuilt geometry and migrate aliases before pruning old context.
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: new Set(keys.keys()) });
|
||||
parts.namedMarkers?.sync(layerState.records);
|
||||
syncSelectionFill();
|
||||
}
|
||||
|
||||
/**
|
||||
* Second paint for floors that missed the bounded pre-render deadline.
|
||||
*
|
||||
* `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so
|
||||
* a cold DEM can never hold the dots hostage — but the resolve keeps running
|
||||
* and lands seconds later, and without this the records it covers stay pinned
|
||||
* at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest
|
||||
* 2026-08-18: "orange dots at the bottom").
|
||||
*
|
||||
* This is the render -> warm -> re-render chain FIRMS already uses, with one
|
||||
* difference the installations path forces: the trigger is whether a cell that
|
||||
* was COLD AT PAINT TIME is warm now, not whether this particular batch warmed
|
||||
* it. The bounded resolve above is still running against the same cells, so
|
||||
* asking "did MY batch warm anything" would answer false exactly when the other
|
||||
* resolve won the race — the common case. Still terminating: a set that is
|
||||
* wholly cold afterwards re-renders zero times and the next camera-driven load
|
||||
* retries.
|
||||
* @param {Array<object>} records Records just rendered.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function warmInstallationFloors(records) {
|
||||
const cold = records
|
||||
.filter(
|
||||
(record) =>
|
||||
cachedGroundFloor(record.latitude, record.longitude) == null,
|
||||
)
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
const owner = layerState.dataSource;
|
||||
warmFireAnchorFloors(cold).then(() => {
|
||||
if (!layerState.enabled || !owner || layerState.dataSource !== owner)
|
||||
return;
|
||||
if (
|
||||
!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)
|
||||
)
|
||||
return;
|
||||
renderRecords();
|
||||
});
|
||||
}
|
||||
return {
|
||||
installationSurfaceHeightM,
|
||||
clearSelectionFill,
|
||||
syncSelectionFill,
|
||||
clearRendered,
|
||||
renderableRecords,
|
||||
renderRecords,
|
||||
warmInstallationFloors,
|
||||
};
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { rgbaFromCss } from '../../../shared/color.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
|
||||
test('Cesium installation outlines use a valid material and retain ground clamping', () => {
|
||||
const id = 'osm:way:980001';
|
||||
const footprint = [
|
||||
[-122.438, 37.758],
|
||||
[-122.402, 37.758],
|
||||
[-122.402, 37.782],
|
||||
[-122.438, 37.782],
|
||||
];
|
||||
const record = {
|
||||
id,
|
||||
kind: 'installation',
|
||||
class: 'airfield',
|
||||
name: 'QA Airfield',
|
||||
latitude: 37.77,
|
||||
longitude: -122.42,
|
||||
footprint,
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
records: [record],
|
||||
recordById: new Map([[id, record]]),
|
||||
renderedKeys: new Map(),
|
||||
dataSource: new Cesium.CustomDataSource('military-installations'),
|
||||
viewer: { scene: { globe: { show: false } } },
|
||||
};
|
||||
const services = {
|
||||
ground: { floorAltitudeM: () => 125, cachedGroundFloor: () => ({}) },
|
||||
anchors: { warmFireAnchorFloors: async () => false },
|
||||
render: { governorRequestRender() {} },
|
||||
context: {
|
||||
getSelectedEntityContext: () => null,
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
};
|
||||
const parts = {
|
||||
model: {
|
||||
colorFor: () => rgbaFromCss('#ff8000'),
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
},
|
||||
};
|
||||
Object.assign(
|
||||
parts,
|
||||
createRendering({ state, services, parts, source: {} }),
|
||||
);
|
||||
|
||||
parts.renderRecords();
|
||||
|
||||
const time = Cesium.JulianDate.now();
|
||||
const marker = state.dataSource.entities.getById(id);
|
||||
const outline = state.dataSource.entities.getById(`${id}:outline:0`);
|
||||
assert.equal(
|
||||
marker.point.heightReference.getValue(time),
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.ok(outline.polyline.material instanceof Cesium.ColorMaterialProperty);
|
||||
assert.equal(outline.polyline.clampToGround.getValue(time), true);
|
||||
for (const position of outline.polyline.positions.getValue(time))
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(position).height,
|
||||
) < 0.01,
|
||||
);
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../../../data/inputOwnership.js';
|
||||
import { LAYER_ID } from '../../policy.js';
|
||||
|
||||
export function createSelection({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { clearSelectedEntityContextForLayer } = services.context;
|
||||
|
||||
let removeContextListeners = null;
|
||||
|
||||
function syncContextSelection(event) {
|
||||
if (!layerState.enabled) return;
|
||||
// Tracking layers publish this event before replacing the shared context.
|
||||
const selected =
|
||||
event?.type === 'gev:awareness-subject-selected'
|
||||
? event.detail
|
||||
: services.context.getSelectedEntityContext();
|
||||
const next =
|
||||
selected?.layerId === LAYER_ID && layerState.recordById.has(selected.id)
|
||||
? selected.id
|
||||
: null;
|
||||
if (next === layerState.selectedId) return;
|
||||
layerState.selectedId = next;
|
||||
parts.rendering.renderRecords();
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
const record = layerState.recordById.get(id);
|
||||
if (!record || !layerState.dataSource) return false;
|
||||
layerState.selectedId = id;
|
||||
parts.rendering.renderRecords({ claimSelection: true });
|
||||
// renderRecords drops selectedId when the record produced no entity.
|
||||
return layerState.selectedId === id;
|
||||
}
|
||||
|
||||
function installInteraction(viewer) {
|
||||
if (layerState.clickHandler) return;
|
||||
const events = [
|
||||
'gev:entity-selected',
|
||||
'gev:entity-selection-cleared',
|
||||
'gev:awareness-subject-selected',
|
||||
];
|
||||
for (const event of events)
|
||||
globalThis.window?.addEventListener?.(event, syncContextSelection);
|
||||
removeContextListeners = () => {
|
||||
for (const event of events)
|
||||
globalThis.window?.removeEventListener?.(event, syncContextSelection);
|
||||
};
|
||||
layerState.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
layerState.clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
if (!layerState.enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id =
|
||||
picked?.id?.installationId ||
|
||||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
|
||||
if (id && layerState.recordById.has(id) && id !== layerState.selectedId) {
|
||||
selectRecord(id);
|
||||
} else if (layerState.selectedId) {
|
||||
// Clicking the selected site again, empty map, or another contact
|
||||
// releases this layer's selection. Clear only our shared context so a
|
||||
// sibling click handler's newly selected aircraft/site stays intact.
|
||||
layerState.selectedId = null;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
parts.rendering.renderRecords();
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
return {
|
||||
selectRecord,
|
||||
installInteraction,
|
||||
destroy() {
|
||||
removeContextListeners?.();
|
||||
removeContextListeners = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
// Mapped Installations on the globe contract (engine.movers, engine.ground,
|
||||
// engine.input, engine.camera), the same on every engine:
|
||||
//
|
||||
// - each rendered site is a 9 px disc in its class colour with a 1 px black
|
||||
// outline at 0.8, always on top (the selected site's is 13 px and white,
|
||||
// in a set of one over the others); a named area's disc is not drawn, its
|
||||
// named-area marker is (./namedMarkers.js);
|
||||
// - every rendered site's outlines, named areas' too, are 2 px ground lines
|
||||
// at 0.85 in its class colour, on terrain and 3D tiles;
|
||||
// - the selected site's footprints are filled at 0.22 in its class colour,
|
||||
// on the 3D tiles while they hide the globe, else on both.
|
||||
//
|
||||
// A pick of a disc, an outline or the fill names the site. The contract has
|
||||
// no ground clamping: a disc sits on its site's cached ground floor (the
|
||||
// ellipsoid until the floor resolves), and floors that resolve late paint
|
||||
// again. A site whose geometry is unchanged keeps its context handle across
|
||||
// repaints, and its outlines stay drawn while a changed set is rebuilt.
|
||||
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../../data/cameraSensitivity.js';
|
||||
import { installationClassLabel, installationTitle } from '../classLabel.js';
|
||||
import { LAYER_ID, MAX_RENDERED } from '../policy.js';
|
||||
import { createNamedMarkers } from './namedMarkers.js';
|
||||
import {
|
||||
MARKER_ORDER,
|
||||
MARKER_OUTLINE_COLOR,
|
||||
MARKER_OUTLINE_PX,
|
||||
MARKER_SIZE_PX,
|
||||
OUTLINE_ALPHA,
|
||||
OUTLINE_ORDER,
|
||||
OUTLINE_WIDTH_PX,
|
||||
SELECTED_MARKER_COLOR,
|
||||
SELECTED_MARKER_ORDER,
|
||||
SELECTED_MARKER_SIZE_PX,
|
||||
SELECTION_FILL_ALPHA,
|
||||
SELECTION_FILL_ORDER,
|
||||
installationAccent,
|
||||
} from './style.js';
|
||||
|
||||
export const MARKER_SET_ID = LAYER_ID;
|
||||
export const SELECTED_MARKER_SET_ID = `${LAYER_ID}-selected`;
|
||||
export const OUTLINE_SET_ID = `${LAYER_ID}-outlines`;
|
||||
export const SELECTION_FILL_SET_ID = `${LAYER_ID}-selection-fill`;
|
||||
|
||||
const CONTEXT_EVENTS = [
|
||||
'gev:entity-selected',
|
||||
'gev:entity-selection-cleared',
|
||||
'gev:awareness-subject-selected',
|
||||
];
|
||||
|
||||
const footprintsOf = (record) =>
|
||||
record.footprints || (record.footprint ? [[record.footprint]] : []);
|
||||
const outlinesOf = (record) =>
|
||||
record.outlineLines || (record.footprint ? [record.footprint] : []);
|
||||
|
||||
/**
|
||||
* Geometry identity of one rendered record. A record whose key is unchanged
|
||||
* keeps its handle and its drawn outlines across refreshes: ground geometry
|
||||
* rebuilds a few frames later on Cesium, so redrawing unchanged sites on
|
||||
* every reload made them blink.
|
||||
* @param {object} record Installation record.
|
||||
* @returns {string} Stable key for the record's drawn geometry.
|
||||
*/
|
||||
export function installationRenderKey(record) {
|
||||
const footprints = footprintsOf(record);
|
||||
const outlines = outlinesOf(record);
|
||||
let points = 0;
|
||||
for (const rings of footprints)
|
||||
for (const ring of rings) points += ring.length;
|
||||
for (const line of outlines) points += line.length;
|
||||
return [
|
||||
record.latitude.toFixed(6),
|
||||
record.longitude.toFixed(6),
|
||||
record.class,
|
||||
record.kind,
|
||||
record.name,
|
||||
footprints.length,
|
||||
outlines.length,
|
||||
points,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* A ground set whose rebuild never blanks what it drew: the next items go
|
||||
* into a second set, and the first is emptied once the second is drawn.
|
||||
*/
|
||||
function createSteadyGroundSet(engine, { id, ...options }) {
|
||||
const sets = [0, 1].map((n) =>
|
||||
engine.ground.createSet({ ...options, id: `${id}-${n}` }),
|
||||
);
|
||||
let front = 0;
|
||||
let pending = false;
|
||||
let removeWatch = null;
|
||||
const stopWatch = () => {
|
||||
removeWatch?.();
|
||||
removeWatch = null;
|
||||
};
|
||||
function settle() {
|
||||
if (!pending || !sets[1 - front].ready()) return;
|
||||
sets[front].replace([]);
|
||||
sets[front].commit();
|
||||
front = 1 - front;
|
||||
pending = false;
|
||||
stopWatch();
|
||||
}
|
||||
return {
|
||||
ids: sets.map((set) => set.id),
|
||||
replace(items) {
|
||||
const next = sets[1 - front];
|
||||
next.replace(items);
|
||||
next.commit();
|
||||
pending = true;
|
||||
settle();
|
||||
if (pending)
|
||||
removeWatch ??= engine.frames.postRender.addEventListener(settle);
|
||||
},
|
||||
clear() {
|
||||
stopWatch();
|
||||
pending = false;
|
||||
for (const set of sets) {
|
||||
set.replace([]);
|
||||
set.commit();
|
||||
}
|
||||
},
|
||||
show(visible) {
|
||||
for (const set of sets) set.show(visible);
|
||||
},
|
||||
destroy() {
|
||||
stopWatch();
|
||||
for (const set of sets) set.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer's renderer parts on `engine`: `{ lifecycle: { methods },
|
||||
* namedMarkers, rendering, selection }` (../index.js assigns them to its
|
||||
* parts).
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ state: object, services: object, parts: object, source: object,
|
||||
* overlayHost: object }} context The layer's state, services, parts,
|
||||
* source and the shared overlay that paints its titles.
|
||||
*/
|
||||
export function createInstallationsDisplay(
|
||||
engine,
|
||||
{ state, services, parts, source, overlayHost },
|
||||
) {
|
||||
if (!engine?.ground)
|
||||
throw new TypeError('The installations display needs a globe engine');
|
||||
const { governorRequestRender } = services.render;
|
||||
const {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
const namedMarkers = createNamedMarkers(engine, {
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
overlayHost,
|
||||
});
|
||||
|
||||
let initialized = false;
|
||||
let visible = false;
|
||||
// Bumped when the renderer detaches: late floor work stops there.
|
||||
let generation = 0;
|
||||
let normal = null,
|
||||
selected = null,
|
||||
outlines = null,
|
||||
fill = null;
|
||||
let fillDrape = null,
|
||||
fillKey = null;
|
||||
let outlineSignature = '';
|
||||
// Record id → { key, record, position, heightM, context handle fields }.
|
||||
const handles = new Map();
|
||||
let drawn = [];
|
||||
let removeClick = null,
|
||||
removeContextListeners = null;
|
||||
|
||||
const colorOf = (record) => parts.model.colorFor(record);
|
||||
const positionAt = (record, heightM) =>
|
||||
geo.cartesian3.fromDegrees(record.longitude, record.latitude, heightM);
|
||||
|
||||
function syncPlacesCredit(records) {
|
||||
const usesPlaces = records.some((record) =>
|
||||
record.sources?.some((item) => item.name === 'Google Maps Places'),
|
||||
);
|
||||
if (usesPlaces)
|
||||
services.credits?.showGooglePlacesCredit?.(engine, LAYER_ID);
|
||||
else services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID);
|
||||
}
|
||||
|
||||
/** The OSM credit while a loaded record comes from OpenStreetMap. */
|
||||
function syncOsmCredit(records) {
|
||||
if (
|
||||
records.some((record) =>
|
||||
record.sources?.some((item) => item.name === 'OpenStreetMap'),
|
||||
)
|
||||
)
|
||||
services.credits?.showOsmCredit?.(engine, LAYER_ID, {
|
||||
openMapTiles: records.some((record) =>
|
||||
record.sources?.some(
|
||||
(item) =>
|
||||
item.name === 'OpenStreetMap' && item.id?.startsWith('tile:'),
|
||||
),
|
||||
),
|
||||
});
|
||||
else services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
}
|
||||
|
||||
function createMarkerSet(id, sizePx, capacity, order) {
|
||||
return engine.movers.createSet({
|
||||
id,
|
||||
kind: 'point',
|
||||
capacity,
|
||||
order,
|
||||
depthTest: 'always',
|
||||
pickable: true,
|
||||
point: {
|
||||
sizePx,
|
||||
outlinePx: MARKER_OUTLINE_PX,
|
||||
outlineColor: MARKER_OUTLINE_COLOR,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The discs of the drawn sites; none while the layer is hidden. */
|
||||
function paintMarkers() {
|
||||
if (!initialized) return;
|
||||
let count = 0;
|
||||
let chosen = null;
|
||||
if (visible)
|
||||
for (const handle of drawn) {
|
||||
const { record } = handle;
|
||||
if (record.namedArea) continue;
|
||||
if (record.id === state.selectedId) {
|
||||
chosen = handle;
|
||||
continue;
|
||||
}
|
||||
normal.set(count++, {
|
||||
position: handle.position,
|
||||
color: colorOf(record).rgba(),
|
||||
pickId: record.id,
|
||||
});
|
||||
}
|
||||
normal.setCount(count);
|
||||
selected.setCount(chosen ? 1 : 0);
|
||||
if (chosen)
|
||||
selected.set(0, {
|
||||
position: chosen.position,
|
||||
color: SELECTED_MARKER_COLOR,
|
||||
pickId: chosen.record.id,
|
||||
});
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
}
|
||||
|
||||
/** The drawn sites' outlines, rebuilt only when one of them changed. */
|
||||
function paintOutlines() {
|
||||
const signature = drawn
|
||||
.map((handle) => `${handle.record.id}#${handle.key}`)
|
||||
.join('\n');
|
||||
if (signature === outlineSignature) return;
|
||||
outlineSignature = signature;
|
||||
const items = [];
|
||||
for (const { record } of drawn) {
|
||||
const color = colorOf(record).withAlpha(OUTLINE_ALPHA).rgba();
|
||||
for (const line of outlinesOf(record))
|
||||
items.push({
|
||||
positions: line,
|
||||
color,
|
||||
widthPx: OUTLINE_WIDTH_PX,
|
||||
pickId: record.id,
|
||||
});
|
||||
}
|
||||
outlines.replace(items);
|
||||
}
|
||||
|
||||
function clearSelectionFill() {
|
||||
if (fill && fillKey !== null) {
|
||||
fill.replace([]);
|
||||
fill.commit();
|
||||
}
|
||||
fillKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected site's footprints, draped on the 3D tiles while they show
|
||||
* (the globe is hidden then), else on terrain and tiles.
|
||||
*/
|
||||
function syncSelectionFill() {
|
||||
const record =
|
||||
initialized && state.enabled && state.recordById.get(state.selectedId);
|
||||
if (!record) {
|
||||
clearSelectionFill();
|
||||
return;
|
||||
}
|
||||
const drape = engine.maps.photorealActive() ? 'tiles' : 'both';
|
||||
const key = `${record.id}|${installationRenderKey(record)}|${drape}`;
|
||||
if (key === fillKey) return;
|
||||
clearSelectionFill();
|
||||
fillKey = key;
|
||||
if (!fill || fillDrape !== drape) {
|
||||
fill?.destroy();
|
||||
fill = engine.ground.createSet({
|
||||
id: SELECTION_FILL_SET_ID,
|
||||
kind: 'polygon',
|
||||
order: SELECTION_FILL_ORDER,
|
||||
pickable: true,
|
||||
drape,
|
||||
});
|
||||
fillDrape = drape;
|
||||
}
|
||||
const color = colorOf(record).withAlpha(SELECTION_FILL_ALPHA).rgba();
|
||||
fill.replace(
|
||||
footprintsOf(record).map((rings) => ({
|
||||
rings,
|
||||
fill: color,
|
||||
pickId: record.id,
|
||||
})),
|
||||
);
|
||||
fill.commit();
|
||||
governorRequestRender('installation-selection-fill');
|
||||
}
|
||||
|
||||
function clearRendered() {
|
||||
clearSelectionFill();
|
||||
handles.clear();
|
||||
drawn = [];
|
||||
outlineSignature = '';
|
||||
if (initialized) {
|
||||
paintMarkers();
|
||||
outlines.clear();
|
||||
}
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records drawn this paint: the nearest `MAX_RENDERED`, plus the
|
||||
* selected one when it falls outside that window.
|
||||
*
|
||||
* Context navigation walks the FULL nearby cohort, which is not bounded by
|
||||
* the render cap, so an item past it had no handle at all: the camera flew,
|
||||
* the selection was silently dropped, and the Context subject stayed stale
|
||||
* so NEXT offered the same installation forever. One extra handle keeps
|
||||
* every cohort item selectable and the cohort count honest.
|
||||
* @returns {Array<object>} Records to render this paint.
|
||||
*/
|
||||
function renderableRecords() {
|
||||
const rendered = state.records
|
||||
.filter((r) => !r.pointOnly)
|
||||
.slice(0, MAX_RENDERED);
|
||||
if (!state.selectedId) return rendered;
|
||||
if (rendered.some((record) => record.id === state.selectedId))
|
||||
return rendered;
|
||||
const chosen = state.recordById.get(state.selectedId);
|
||||
return chosen ? [...rendered, chosen] : rendered;
|
||||
}
|
||||
|
||||
function contextOf(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
layerId: LAYER_ID,
|
||||
layerName:
|
||||
record.kind === 'place_candidate'
|
||||
? 'Military Site Search Candidates'
|
||||
: 'Mapped Military Installations',
|
||||
source: parts.model.installationSourceLabel(record),
|
||||
label: record.name,
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
class: record.class,
|
||||
memberNames: record.memberNames || [],
|
||||
primaryType: record.primaryType || null,
|
||||
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
|
||||
validation: record.validation,
|
||||
retrievedAt: record.retrievedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A drawn site's context handle, with what the readouts read of it. */
|
||||
function createHandle(record, key, heightM) {
|
||||
const handle = {
|
||||
id: record.id,
|
||||
key,
|
||||
record,
|
||||
heightM,
|
||||
position: positionAt(record, heightM),
|
||||
gevTrackedId: `installations:${record.id}`,
|
||||
gevLabelModel: {
|
||||
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
],
|
||||
accent: installationAccent(record),
|
||||
},
|
||||
};
|
||||
handle.gevDisplayPosition = () => handle.position;
|
||||
registerEntityContext(handle, contextOf(record));
|
||||
return handle;
|
||||
}
|
||||
|
||||
function renderRecords({ claimSelection = false } = {}) {
|
||||
if (!initialized) return;
|
||||
// Context navigation can select another layer without a canvas click.
|
||||
// A delayed floor/data repaint must not steal that newer selection back.
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
if (
|
||||
!claimSelection &&
|
||||
state.selectedId &&
|
||||
selectedContext?.id !== state.selectedId
|
||||
)
|
||||
state.selectedId = null;
|
||||
let migratedSelection = false;
|
||||
if (state.selectedId && !state.recordById.has(state.selectedId)) {
|
||||
const replacement = state.records.find((record) =>
|
||||
record.aliasIds?.includes(state.selectedId),
|
||||
);
|
||||
if (replacement) {
|
||||
state.selectedId = replacement.id;
|
||||
migratedSelection = true;
|
||||
}
|
||||
}
|
||||
// Post-moveEnd debounced fetches commit after the camera settles; the
|
||||
// rebuilt sets need one frame in idle mode.
|
||||
governorRequestRender('installations-render');
|
||||
syncOsmCredit(state.records);
|
||||
syncPlacesCredit(state.records);
|
||||
const records = renderableRecords();
|
||||
const keys = new Map(
|
||||
records.map((record) => [record.id, installationRenderKey(record)]),
|
||||
);
|
||||
for (const [id, handle] of [...handles])
|
||||
if (keys.get(id) !== handle.key) handles.delete(id);
|
||||
drawn = records.map((record) => {
|
||||
const heightM = parts.model.installationSurfaceHeightM(record);
|
||||
const handle = handles.get(record.id);
|
||||
if (!handle) {
|
||||
const created = createHandle(record, keys.get(record.id), heightM);
|
||||
handles.set(record.id, created);
|
||||
return created;
|
||||
}
|
||||
// Same geometry: the disc moves to its floor in place.
|
||||
handle.record = record;
|
||||
if (handle.heightM !== heightM) {
|
||||
handle.heightM = heightM;
|
||||
handle.position = positionAt(record, heightM);
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
paintMarkers();
|
||||
paintOutlines();
|
||||
const chosen = state.selectedId ? handles.get(state.selectedId) : null;
|
||||
if (chosen && (claimSelection || migratedSelection))
|
||||
selectEntityContext(chosen);
|
||||
else if (!chosen) state.selectedId = null;
|
||||
// Re-register rebuilt geometry and migrate aliases before pruning old context.
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: new Set(keys.keys()) });
|
||||
namedMarkers.sync(state.records);
|
||||
syncSelectionFill();
|
||||
}
|
||||
|
||||
/**
|
||||
* Second paint for floors that missed the bounded pre-render deadline.
|
||||
*
|
||||
* `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so
|
||||
* a cold DEM can never hold the dots hostage, but the resolve keeps running
|
||||
* and lands seconds later; without this the records it covers stay pinned
|
||||
* at ellipsoid height 0, visibly under the 3D tiles.
|
||||
*
|
||||
* The trigger is whether a cell that was COLD AT PAINT TIME is warm now,
|
||||
* not whether this batch warmed it: the bounded resolve is still running
|
||||
* against the same cells and usually wins the race. Still terminating: a
|
||||
* set that is wholly cold afterwards re-renders zero times and the next
|
||||
* camera-driven load retries.
|
||||
* @param {Array<object>} records Records just rendered.
|
||||
* @returns {void}
|
||||
*/
|
||||
function warmInstallationFloors(records) {
|
||||
const { cachedGroundFloor } = services.ground;
|
||||
const cold = records
|
||||
.filter(
|
||||
(record) =>
|
||||
cachedGroundFloor(record.latitude, record.longitude) == null,
|
||||
)
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
const epoch = generation;
|
||||
services.anchors.warmFireAnchorFloors(cold).then(() => {
|
||||
if (!state.enabled || !initialized || epoch !== generation) return;
|
||||
if (
|
||||
!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)
|
||||
)
|
||||
return;
|
||||
renderRecords();
|
||||
});
|
||||
}
|
||||
|
||||
function syncContextSelection(event) {
|
||||
if (!state.enabled) return;
|
||||
// Tracking layers publish this event before replacing the shared context.
|
||||
const current =
|
||||
event?.type === 'gev:awareness-subject-selected'
|
||||
? event.detail
|
||||
: getSelectedEntityContext();
|
||||
const next =
|
||||
current?.layerId === LAYER_ID && state.recordById.has(current.id)
|
||||
? current.id
|
||||
: null;
|
||||
if (next === state.selectedId) return;
|
||||
state.selectedId = next;
|
||||
renderRecords();
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
if (!state.recordById.has(id) || !initialized) return false;
|
||||
state.selectedId = id;
|
||||
renderRecords({ claimSelection: true });
|
||||
// renderRecords drops selectedId when the record got no handle.
|
||||
return state.selectedId === id;
|
||||
}
|
||||
|
||||
function onClick({ picked }) {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
if (!state.enabled) return;
|
||||
const id = typeof picked?.id === 'string' ? picked.id : null;
|
||||
if (id && state.recordById.has(id) && id !== state.selectedId) {
|
||||
selectRecord(id);
|
||||
} else if (state.selectedId) {
|
||||
// Clicking the selected site again, empty map, or another contact
|
||||
// releases this layer's selection. Clear only our shared context so a
|
||||
// sibling click handler's newly selected aircraft/site stays intact.
|
||||
state.selectedId = null;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
renderRecords();
|
||||
}
|
||||
}
|
||||
|
||||
function installInteraction() {
|
||||
if (removeClick) return;
|
||||
for (const type of CONTEXT_EVENTS)
|
||||
globalThis.window?.addEventListener?.(type, syncContextSelection);
|
||||
removeContextListeners = () => {
|
||||
for (const type of CONTEXT_EVENTS)
|
||||
globalThis.window?.removeEventListener?.(type, syncContextSelection);
|
||||
};
|
||||
removeClick = engine.input.on('click', onClick, { pick: true });
|
||||
}
|
||||
|
||||
function destroyInteraction() {
|
||||
removeContextListeners?.();
|
||||
removeContextListeners = null;
|
||||
removeClick?.();
|
||||
removeClick = null;
|
||||
}
|
||||
|
||||
function setVisible(next) {
|
||||
visible = next;
|
||||
paintMarkers();
|
||||
outlines?.show(next);
|
||||
}
|
||||
|
||||
const methods = {
|
||||
init() {
|
||||
if (initialized)
|
||||
throw new Error('Installations display is already initialized');
|
||||
state.engine = engine;
|
||||
normal = createMarkerSet(
|
||||
MARKER_SET_ID,
|
||||
MARKER_SIZE_PX,
|
||||
MAX_RENDERED + 1,
|
||||
MARKER_ORDER,
|
||||
);
|
||||
selected = createMarkerSet(
|
||||
SELECTED_MARKER_SET_ID,
|
||||
SELECTED_MARKER_SIZE_PX,
|
||||
1,
|
||||
SELECTED_MARKER_ORDER,
|
||||
);
|
||||
outlines = createSteadyGroundSet(engine, {
|
||||
id: OUTLINE_SET_ID,
|
||||
kind: 'corridor',
|
||||
order: OUTLINE_ORDER,
|
||||
pickable: true,
|
||||
});
|
||||
outlines.show(false);
|
||||
initialized = true;
|
||||
const cameraChanged = () => {
|
||||
if (!state.contextAnchor) parts.viewport.scheduleLoad();
|
||||
};
|
||||
state.moveEndRemove =
|
||||
engine.camera.moveEnd.addEventListener(cameraChanged);
|
||||
state.changedRemove =
|
||||
engine.camera.changed.addEventListener(cameraChanged);
|
||||
installInteraction();
|
||||
},
|
||||
|
||||
enable() {
|
||||
state.enabled = true;
|
||||
state.surfaceChangedRemove ||=
|
||||
services.maps?.subscribeMapStack(syncSelectionFill);
|
||||
// Cached markers and geometry become visible before the next fetch.
|
||||
if (
|
||||
state.records?.some((r) =>
|
||||
r.sources?.some((s) => s.name === 'OpenStreetMap'),
|
||||
)
|
||||
)
|
||||
syncOsmCredit(state.records);
|
||||
namedMarkers.enable();
|
||||
namedMarkers.sync(state.records || []);
|
||||
claimCameraSensitivity(engine, LAYER_ID, 0.01);
|
||||
services.picking.registerPickOwner(LAYER_ID, (id) =>
|
||||
state.recordById.has(id),
|
||||
);
|
||||
setVisible(true);
|
||||
// The layer manager calls update() right after enable(), which owns
|
||||
// the first fetch. A second, aborting request here would race it.
|
||||
},
|
||||
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID);
|
||||
namedMarkers.hide();
|
||||
releaseCameraSensitivity(engine, LAYER_ID);
|
||||
state.contextAnchor = null;
|
||||
state.contextPosition = null;
|
||||
state.coverage = { kind: 'viewport' };
|
||||
state.cameraLoadKey = null;
|
||||
state.cameraLoadOwner = null;
|
||||
services.picking.unregisterPickOwner(LAYER_ID);
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
clearTimeout(state.timer);
|
||||
state.abort?.abort();
|
||||
state.abort = null;
|
||||
state.loading = false;
|
||||
setVisible(false);
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
state.selectedId = null;
|
||||
clearSelectionFill();
|
||||
state.surfaceChangedRemove?.();
|
||||
state.surfaceChangedRemove = null;
|
||||
state.failureReason = null;
|
||||
parts.ingestion.setInstallationStatus('idle');
|
||||
},
|
||||
|
||||
destroy() {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
this.detach();
|
||||
},
|
||||
|
||||
detach() {
|
||||
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID);
|
||||
services.picking.unregisterPickOwner(LAYER_ID);
|
||||
releaseCameraSensitivity(engine, LAYER_ID);
|
||||
state.surfaceChangedRemove?.();
|
||||
state.surfaceChangedRemove = null;
|
||||
state.moveEndRemove?.();
|
||||
state.changedRemove?.();
|
||||
state.moveEndRemove = state.changedRemove = null;
|
||||
destroyInteraction();
|
||||
namedMarkers.destroy();
|
||||
clearRendered();
|
||||
generation++;
|
||||
if (initialized) {
|
||||
normal.destroy();
|
||||
selected.destroy();
|
||||
outlines.destroy();
|
||||
fill?.destroy();
|
||||
}
|
||||
normal = selected = outlines = fill = null;
|
||||
fillDrape = null;
|
||||
initialized = false;
|
||||
visible = false;
|
||||
state.engine = null;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
lifecycle: { methods },
|
||||
namedMarkers,
|
||||
rendering: {
|
||||
installationSurfaceHeightM: parts.model.installationSurfaceHeightM,
|
||||
clearSelectionFill,
|
||||
syncSelectionFill,
|
||||
clearRendered,
|
||||
renderableRecords,
|
||||
renderRecords,
|
||||
warmInstallationFloors,
|
||||
},
|
||||
selection: {
|
||||
selectRecord,
|
||||
installInteraction,
|
||||
destroy: destroyInteraction,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// Mapped Installations on the engine contract, on the fake engine and on the
|
||||
// Cesium engine: the sites' discs, the named areas' outlines, the ground
|
||||
// outlines and the selection fill, what a pick selects, the context handles,
|
||||
// late floors and teardown.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import * as context from '../../../data/contextStore.js';
|
||||
import { claimPointer, releasePointer } from '../../../data/inputOwnership.js';
|
||||
import { setLocale } from '../../../i18n/index.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { installationClassLabel, installationTitle } from '../classLabel.js';
|
||||
import { COLOR_BY_CLASS, LAYER_ID } from '../policy.js';
|
||||
import {
|
||||
MARKER_SET_ID,
|
||||
OUTLINE_SET_ID,
|
||||
SELECTED_MARKER_SET_ID,
|
||||
SELECTION_FILL_SET_ID,
|
||||
} from './display.js';
|
||||
import { NAMED_MARKER_SET_ID } from './namedMarkers.js';
|
||||
import { createDisplayHarness } from './displayHarness.mjs';
|
||||
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
|
||||
|
||||
const ring = (west, south, size = 0.01) => [
|
||||
[west, south],
|
||||
[west + size, south],
|
||||
[west + size, south + size],
|
||||
[west, south + size],
|
||||
[west, south],
|
||||
];
|
||||
|
||||
const airfield = () => ({
|
||||
id: 'osm:way:1',
|
||||
kind: 'installation',
|
||||
class: 'airfield',
|
||||
name: 'QA Airfield',
|
||||
latitude: 30.005,
|
||||
longitude: -96.995,
|
||||
footprint: ring(-97, 30),
|
||||
sources: [{ name: 'OpenStreetMap', id: 'way:1' }],
|
||||
});
|
||||
|
||||
const namedRange = () => ({
|
||||
id: 'osm:military:w2',
|
||||
kind: 'installation',
|
||||
class: 'range',
|
||||
name: 'Named Range',
|
||||
namedArea: true,
|
||||
areaM2: 5e6,
|
||||
latitude: 30.105,
|
||||
longitude: -97.095,
|
||||
footprints: [[ring(-97.1, 30.1), ring(-97.098, 30.102, 0.002)]],
|
||||
outlineLines: [ring(-97.1, 30.1), ring(-97.098, 30.102, 0.002)],
|
||||
});
|
||||
|
||||
const classColor = (record, alpha) =>
|
||||
rgbaFromCss(COLOR_BY_CLASS[record.class]).withAlpha(alpha).rgba();
|
||||
|
||||
function closeTo(actual, expected, message) {
|
||||
assert.equal(actual.length, expected.length, message);
|
||||
actual.forEach((value, index) =>
|
||||
assert.ok(Math.abs(value - expected[index]) < 1e-6, message),
|
||||
);
|
||||
}
|
||||
|
||||
/** A started display over Texas, with the window the context store needs. */
|
||||
function setup(t, makeHarness, records = [airfield()], options) {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = new EventTarget();
|
||||
const h = createDisplayHarness(makeHarness, options);
|
||||
h.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97, 30, 50_000),
|
||||
});
|
||||
h.setRecords(records);
|
||||
h.start();
|
||||
t.after(() => {
|
||||
h.destroy();
|
||||
context.removeEntityContextsForLayer(LAYER_ID);
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The outline set holding the drawn outlines (the other one is empty). */
|
||||
function drawnOutlines(h) {
|
||||
const sets = [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`));
|
||||
return sets.find((set) => set.items.length) ?? sets[1];
|
||||
}
|
||||
|
||||
for (const makeHarness of GROUND_HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`sites are 9 px discs in their class colour, 13 px and white when selected, always on top (${name})`, (t) => {
|
||||
const h = setup(t, makeHarness);
|
||||
h.parts.rendering.renderRecords();
|
||||
const [disc] = h.markers(MARKER_SET_ID);
|
||||
assert.equal(h.markers(MARKER_SET_ID).length, 1);
|
||||
assert.equal(disc.pickId, 'osm:way:1', 'a pick names the site');
|
||||
closeTo(disc.color, classColor(airfield(), 1));
|
||||
assert.equal(h.markers(SELECTED_MARKER_SET_ID).length, 0);
|
||||
if (h.cesium) {
|
||||
const point = disc.point;
|
||||
assert.ok(
|
||||
h.collection(MARKER_SET_ID) instanceof Cesium.PointPrimitiveCollection,
|
||||
);
|
||||
assert.equal(point.pixelSize, 9);
|
||||
assert.equal(point.outlineWidth, 1);
|
||||
assert.ok(
|
||||
point.outlineColor.equalsEpsilon(
|
||||
Cesium.Color.BLACK.withAlpha(0.8),
|
||||
1e-6,
|
||||
),
|
||||
);
|
||||
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
|
||||
} else {
|
||||
const { options } = h.engine.fake.moverSet(MARKER_SET_ID);
|
||||
assert.equal(options.kind, 'point');
|
||||
assert.equal(options.point.sizePx, 9);
|
||||
assert.equal(options.point.outlinePx, 1);
|
||||
assert.deepEqual(options.point.outlineColor, [0, 0, 0, 0.8]);
|
||||
assert.equal(options.depthTest, 'always');
|
||||
assert.equal(options.pickable, true);
|
||||
assert.ok(options.order < 0, 'under the moving contacts');
|
||||
const selected = h.engine.fake.moverSet(SELECTED_MARKER_SET_ID).options;
|
||||
assert.equal(selected.point.sizePx, 13);
|
||||
assert.ok(selected.order > options.order, 'over the other discs');
|
||||
}
|
||||
|
||||
assert.equal(h.parts.selection.selectRecord('osm:way:1'), true);
|
||||
assert.equal(h.markers(MARKER_SET_ID).length, 0);
|
||||
const [chosen] = h.markers(SELECTED_MARKER_SET_ID);
|
||||
assert.equal(chosen.pickId, 'osm:way:1');
|
||||
closeTo(chosen.color, [1, 1, 1, 1]);
|
||||
if (h.cesium) assert.equal(chosen.point.pixelSize, 13);
|
||||
});
|
||||
|
||||
test(`a named area draws no disc but its outlines are drawn, as on Cesium (${name})`, (t) => {
|
||||
const named = namedRange();
|
||||
const h = setup(t, makeHarness, [airfield(), named]);
|
||||
h.parts.rendering.renderRecords();
|
||||
assert.deepEqual(
|
||||
h.markers(MARKER_SET_ID).map((disc) => disc.pickId),
|
||||
['osm:way:1'],
|
||||
'the named area has no site disc',
|
||||
);
|
||||
const outlines = drawnOutlines(h).items.filter(
|
||||
(item) => item.pickId === named.id,
|
||||
);
|
||||
assert.equal(outlines.length, 2, 'every outline line of the named area');
|
||||
assert.deepEqual(outlines[0].positions, named.outlineLines[0]);
|
||||
closeTo(outlines[0].color, classColor(named, 0.85));
|
||||
const namedDiscs = h.markers(NAMED_MARKER_SET_ID);
|
||||
assert.equal(
|
||||
namedDiscs[0].pickId,
|
||||
named.id,
|
||||
'its named-area disc is drawn',
|
||||
);
|
||||
assert.equal(namedDiscs[0].show, true);
|
||||
// Selected, it still draws no site disc, and its outlines stay.
|
||||
h.parts.selection.selectRecord(named.id);
|
||||
assert.equal(h.markers(SELECTED_MARKER_SET_ID).length, 0);
|
||||
assert.equal(
|
||||
drawnOutlines(h).items.filter((item) => item.pickId === named.id).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test(`outlines are 2 px ground lines at 0.85 on both surfaces, rebuilt only for a changed site (${name})`, (t) => {
|
||||
const h = setup(t, makeHarness);
|
||||
h.parts.rendering.renderRecords();
|
||||
const set = drawnOutlines(h);
|
||||
assert.equal(set.options.kind, 'corridor');
|
||||
assert.equal(set.options.drape, 'both');
|
||||
assert.equal(set.options.pickable, true);
|
||||
assert.equal(set.items.length, 1);
|
||||
const [line] = set.items;
|
||||
assert.equal(line.widthPx, 2);
|
||||
assert.equal(line.pickId, 'osm:way:1');
|
||||
closeTo(line.color, classColor(airfield(), 0.85));
|
||||
if (h.cesium) {
|
||||
const [primitive] = h.primitives(`${OUTLINE_SET_ID}-1`).lines;
|
||||
assert.equal(
|
||||
primitive.options.classificationType,
|
||||
Cesium.ClassificationType.BOTH,
|
||||
);
|
||||
}
|
||||
// Cesium builds ground lines in workers: the first set is drawn now.
|
||||
const built = (n) => {
|
||||
for (const primitive of h.primitives(`${OUTLINE_SET_ID}-${n}`).lines)
|
||||
primitive.ready = true;
|
||||
h.viewer.scene.postRender.raiseEvent();
|
||||
};
|
||||
if (h.cesium) built(1);
|
||||
const commits = [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits);
|
||||
for (let i = 0; i < 5; i++) h.parts.rendering.renderRecords();
|
||||
assert.deepEqual(
|
||||
[0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits),
|
||||
commits,
|
||||
'repaints with the same geometry rebuild nothing',
|
||||
);
|
||||
|
||||
// A changed site rebuilds the set; the drawn outlines stay until the
|
||||
// new ones are drawn.
|
||||
h.setRecords([
|
||||
airfield(),
|
||||
{ ...airfield(), id: 'osm:way:3', latitude: 30.2 },
|
||||
]);
|
||||
h.parts.rendering.renderRecords();
|
||||
const counts = () =>
|
||||
[0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).items.length);
|
||||
if (h.cesium) {
|
||||
assert.deepEqual(
|
||||
counts(),
|
||||
[2, 1],
|
||||
'Cesium is still building the new set',
|
||||
);
|
||||
built(0);
|
||||
}
|
||||
assert.deepEqual(counts(), [2, 0]);
|
||||
});
|
||||
|
||||
test(`the selected footprints fill at 0.22 with their holes, on the tiles under photoreal (${name})`, (t) => {
|
||||
const named = namedRange();
|
||||
const h = setup(t, makeHarness, [named]);
|
||||
h.setPhotoreal(true);
|
||||
h.parts.rendering.renderRecords();
|
||||
assert.equal(
|
||||
h.set(SELECTION_FILL_SET_ID),
|
||||
undefined,
|
||||
'no fill before a selection',
|
||||
);
|
||||
h.parts.selection.selectRecord(named.id);
|
||||
const fill = h.set(SELECTION_FILL_SET_ID);
|
||||
assert.equal(fill.options.drape, 'tiles');
|
||||
assert.equal(fill.options.kind, 'polygon');
|
||||
assert.ok(
|
||||
fill.options.order < drawnOutlines(h).options.order,
|
||||
'under the outlines',
|
||||
);
|
||||
assert.equal(fill.items.length, 1, 'one polygon per footprint');
|
||||
assert.equal(fill.items[0].rings.length, 2, 'with its hole');
|
||||
assert.equal(fill.items[0].pickId, named.id);
|
||||
assert.equal(fill.items[0].outline, undefined);
|
||||
closeTo(fill.items[0].fill, classColor(named, 0.22));
|
||||
if (h.cesium) {
|
||||
const [primitive] = h.primitives(SELECTION_FILL_SET_ID).fills;
|
||||
assert.equal(
|
||||
primitive.options.classificationType,
|
||||
Cesium.ClassificationType.CESIUM_3D_TILE,
|
||||
);
|
||||
}
|
||||
const commits = fill.commits;
|
||||
for (let i = 0; i < 10; i++) h.parts.rendering.renderRecords();
|
||||
assert.equal(
|
||||
h.set(SELECTION_FILL_SET_ID).commits,
|
||||
commits,
|
||||
'repaints keep the fill',
|
||||
);
|
||||
|
||||
// The map stack changes to the globe: the fill drapes on both.
|
||||
h.setPhotoreal(false);
|
||||
h.changeMapStack();
|
||||
assert.equal(h.set(SELECTION_FILL_SET_ID).options.drape, 'both');
|
||||
assert.equal(h.set(SELECTION_FILL_SET_ID).items.length, 1);
|
||||
|
||||
h.parts.lifecycle.methods.disable();
|
||||
assert.equal(h.set(SELECTION_FILL_SET_ID).items.length, 0);
|
||||
assert.equal(
|
||||
h.mapStackRemoved(),
|
||||
1,
|
||||
'disable drops the map-stack listener',
|
||||
);
|
||||
h.parts.lifecycle.methods.enable();
|
||||
h.parts.rendering.syncSelectionFill();
|
||||
assert.equal(
|
||||
h.set(SELECTION_FILL_SET_ID).items.length,
|
||||
0,
|
||||
'reenabling cannot revive a stale selection',
|
||||
);
|
||||
});
|
||||
|
||||
test(`a click on a disc, an outline or the fill selects the site; the next click releases it (${name})`, (t) => {
|
||||
const h = setup(t, makeHarness);
|
||||
h.parts.rendering.renderRecords();
|
||||
h.click({ id: 'osm:way:1' });
|
||||
assert.equal(h.state.selectedId, 'osm:way:1');
|
||||
assert.equal(context.getSelectedEntityContext()?.id, 'osm:way:1');
|
||||
h.click({ id: 'osm:way:1' });
|
||||
assert.equal(
|
||||
h.state.selectedId,
|
||||
null,
|
||||
'clicking the selected site releases it',
|
||||
);
|
||||
assert.equal(context.getSelectedEntityContext(), null);
|
||||
|
||||
h.click({ set: `${OUTLINE_SET_ID}-1`, instance: 0 });
|
||||
assert.equal(
|
||||
h.state.selectedId,
|
||||
'osm:way:1',
|
||||
'an outline selects its site',
|
||||
);
|
||||
h.click({ id: 'aircraft:1' });
|
||||
assert.equal(h.state.selectedId, null, 'another contact releases it');
|
||||
h.parts.selection.selectRecord('osm:way:1');
|
||||
h.click({ set: SELECTION_FILL_SET_ID, instance: 0 });
|
||||
assert.equal(
|
||||
h.state.selectedId,
|
||||
null,
|
||||
'the fill of the selected site releases it',
|
||||
);
|
||||
|
||||
const lease = claimPointer('draw');
|
||||
try {
|
||||
h.click({ id: 'osm:way:1' });
|
||||
assert.equal(
|
||||
h.state.selectedId,
|
||||
null,
|
||||
'a tool that owns the pointer keeps it',
|
||||
);
|
||||
} finally {
|
||||
releasePointer(lease);
|
||||
}
|
||||
h.parts.lifecycle.methods.disable();
|
||||
h.click({ id: 'osm:way:1' });
|
||||
assert.equal(h.state.selectedId, null, 'a disabled layer ignores clicks');
|
||||
});
|
||||
|
||||
test(`each drawn site has a context handle on its floor, kept while its geometry is unchanged (${name})`, (t) => {
|
||||
const h = setup(t, makeHarness);
|
||||
const record = airfield();
|
||||
h.floors.set(`${record.latitude},${record.longitude}`, 125);
|
||||
try {
|
||||
setLocale('ru');
|
||||
h.parts.rendering.renderRecords();
|
||||
const handle = context.getContextStore().entities.get(record.id).entity;
|
||||
assert.equal(handle.gevTrackedId, `installations:${record.id}`);
|
||||
assert.deepEqual(handle.gevLabelModel, {
|
||||
title: installationTitle('QA Airfield'),
|
||||
details: [installationClassLabel('airfield')],
|
||||
accent: COLOR_BY_CLASS.airfield,
|
||||
});
|
||||
assert.notEqual(
|
||||
handle.gevLabelModel.details[0],
|
||||
'AIRFIELD',
|
||||
'in the UI language',
|
||||
);
|
||||
const height = geo.cartographic.fromCartesian(
|
||||
handle.gevDisplayPosition(),
|
||||
).height;
|
||||
assert.ok(Math.abs(height - 125) < 0.01, 'on the cached ground floor');
|
||||
const entry = context.getContextStore().entities.get(record.id);
|
||||
assert.equal(entry.layerName, 'Mapped Military Installations');
|
||||
assert.equal(entry.source, 'OpenStreetMap');
|
||||
|
||||
h.parts.rendering.renderRecords();
|
||||
assert.equal(
|
||||
context.getContextStore().entities.get(record.id).entity,
|
||||
handle,
|
||||
'a repaint with the same geometry keeps the handle',
|
||||
);
|
||||
h.setRecords([{ ...record, latitude: record.latitude + 0.001 }]);
|
||||
h.parts.rendering.renderRecords();
|
||||
assert.notEqual(
|
||||
context.getContextStore().entities.get(record.id).entity,
|
||||
handle,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test(`floors that resolve late paint the discs again (${name})`, async (t) => {
|
||||
let warm;
|
||||
const h = setup(t, makeHarness, [airfield()], {
|
||||
warmFloors: () => new Promise((resolve) => (warm = resolve)),
|
||||
});
|
||||
const record = airfield();
|
||||
h.parts.rendering.renderRecords();
|
||||
const heightOf = () =>
|
||||
geo.cartographic.fromCartesian(h.markers(MARKER_SET_ID)[0].position)
|
||||
.height;
|
||||
assert.ok(
|
||||
Math.abs(heightOf()) < 0.01,
|
||||
'a cold floor sits on the ellipsoid',
|
||||
);
|
||||
h.parts.rendering.warmInstallationFloors([record]);
|
||||
h.floors.set(`${record.latitude},${record.longitude}`, 2400);
|
||||
warm(true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(
|
||||
Math.abs(heightOf() - 2400) < 0.01,
|
||||
'the late floor lifts the disc',
|
||||
);
|
||||
});
|
||||
|
||||
test(`teardown removes every set and listener (${name})`, (t) => {
|
||||
const h = setup(t, makeHarness, [airfield(), namedRange()]);
|
||||
const changed = h.engine.camera.changed.numberOfListeners;
|
||||
h.parts.rendering.renderRecords();
|
||||
h.parts.selection.selectRecord('osm:way:1');
|
||||
assert.ok(h.listeners() > 0);
|
||||
h.parts.lifecycle.methods.detach();
|
||||
assert.equal(h.listeners(), 0, 'no click listener');
|
||||
assert.equal(
|
||||
h.engine.camera.changed.numberOfListeners,
|
||||
changed - 2,
|
||||
'the viewport and named-marker camera listeners go',
|
||||
);
|
||||
assert.deepEqual(
|
||||
h.engine.movers.list().filter(({ id }) => id.startsWith(LAYER_ID)),
|
||||
[],
|
||||
);
|
||||
assert.equal(h.sets(), 0, 'no ground set');
|
||||
assert.equal(context.getContextStore().entities.has('osm:way:1'), false);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Mapped Installations' display under test, on a ground harness of
|
||||
// src/testSupport/groundDisplayEngines.mjs (the fake engine or the Cesium
|
||||
// engine, passed in): the layer's own
|
||||
// state and model over test services, with the display's parts assigned as
|
||||
// the layer assigns them (../index.js). `markers(setId)` reads a point
|
||||
// set's drawn instances on either engine.
|
||||
|
||||
import * as contextStore from '../../../data/contextStore.js';
|
||||
import { createModel } from '../model.js';
|
||||
import { createState } from '../state.js';
|
||||
import { createInstallationsDisplay } from './display.js';
|
||||
|
||||
const children = (collection) =>
|
||||
Array.from({ length: collection?.length ?? 0 }, (_, index) =>
|
||||
collection.get(index),
|
||||
);
|
||||
|
||||
/** The Cesium collection drawing a mover set. */
|
||||
function cesiumCollection(viewer, id) {
|
||||
for (const root of children(viewer.scene.primitives))
|
||||
for (const child of children(root))
|
||||
if (child.gevMoverSet === id) return child;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Function} makeEngine A ground harness factory.
|
||||
* @param {{ context?: object, overlayHost?: object,
|
||||
* warmFloors?: (cells: object[]) => Promise<any> }} [options]
|
||||
* `context` replaces the shared context store.
|
||||
*/
|
||||
export function createDisplayHarness(
|
||||
makeEngine,
|
||||
{ context = contextStore, overlayHost = null, warmFloors = null } = {},
|
||||
) {
|
||||
// The context store lives on the window.
|
||||
const ownWindow = globalThis.window === undefined;
|
||||
if (ownWindow) globalThis.window = new EventTarget();
|
||||
const h = makeEngine();
|
||||
const state = createState({ services: {} });
|
||||
const floors = new Map();
|
||||
const labels = { entries: [], policy: null, visible: false, publications: 0 };
|
||||
let mapStackListener = null;
|
||||
let mapStackRemoved = 0;
|
||||
let loads = 0;
|
||||
let photoreal = false;
|
||||
h.engine.maps.photorealActive = () => photoreal;
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
context,
|
||||
ground: {
|
||||
cachedGroundFloor: (lat, lon) => floors.get(`${lat},${lon}`) ?? null,
|
||||
floorAltitudeM: (_position, floor) => floor ?? null,
|
||||
},
|
||||
anchors: { warmFireAnchorFloors: warmFloors ?? (async () => false) },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
maps: {
|
||||
subscribeMapStack(callback) {
|
||||
mapStackListener = callback;
|
||||
return () => {
|
||||
mapStackListener = null;
|
||||
mapStackRemoved++;
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const parts = {};
|
||||
const ctx = {
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
source: {},
|
||||
overlayHost: overlayHost ?? {
|
||||
setEntries(_id, entries, policy) {
|
||||
labels.entries = entries;
|
||||
labels.policy = policy;
|
||||
labels.publications++;
|
||||
},
|
||||
clearSource() {
|
||||
labels.entries = [];
|
||||
},
|
||||
setVisible(_id, visible) {
|
||||
labels.visible = visible;
|
||||
},
|
||||
},
|
||||
};
|
||||
parts.model = createModel(ctx);
|
||||
parts.viewport = {
|
||||
scheduleLoad: () => loads++,
|
||||
clearUnavailableRetry() {},
|
||||
};
|
||||
parts.ingestion = { setInstallationStatus() {} };
|
||||
Object.assign(parts, createInstallationsDisplay(h.engine, ctx));
|
||||
const methods = parts.lifecycle.methods;
|
||||
return {
|
||||
...h,
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
labels,
|
||||
floors,
|
||||
loads: () => loads,
|
||||
setRecords(records) {
|
||||
state.records = records;
|
||||
state.recordById = new Map(records.map((record) => [record.id, record]));
|
||||
},
|
||||
/** init() and enable(), as the layer manager runs them. */
|
||||
start() {
|
||||
methods.init();
|
||||
methods.enable();
|
||||
},
|
||||
setPhotoreal(value) {
|
||||
photoreal = value;
|
||||
},
|
||||
changeMapStack: () => mapStackListener?.(),
|
||||
mapStackRemoved: () => mapStackRemoved,
|
||||
/** A point set's drawn instances: pickId, color (rgba 0..1), show, position. */
|
||||
markers(id) {
|
||||
const count =
|
||||
h.engine.movers.list().find((entry) => entry.id === id)?.count ?? 0;
|
||||
if (h.cesium) {
|
||||
const collection = cesiumCollection(h.viewer, id);
|
||||
return children(collection)
|
||||
.slice(0, count)
|
||||
.map((point) => ({
|
||||
pickId: point.id,
|
||||
color: [
|
||||
point.color.red,
|
||||
point.color.green,
|
||||
point.color.blue,
|
||||
point.color.alpha,
|
||||
],
|
||||
show: point.show,
|
||||
position: point.position,
|
||||
point,
|
||||
}));
|
||||
}
|
||||
const { store } = h.engine.fake.moverSet(id);
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
pickId: store.pickIds[index],
|
||||
color: Array.from(store.colors.slice(index * 4, index * 4 + 4)),
|
||||
show: store.shows[index] === 1,
|
||||
position: {
|
||||
x: store.positions[index * 3],
|
||||
y: store.positions[index * 3 + 1],
|
||||
z: store.positions[index * 3 + 2],
|
||||
},
|
||||
}));
|
||||
},
|
||||
/** The Cesium collection of a point set (Cesium only). */
|
||||
collection: (id) => cesiumCollection(h.viewer, id),
|
||||
destroy() {
|
||||
methods.destroy();
|
||||
h.destroy();
|
||||
if (ownWindow) delete globalThis.window;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// The named-area discs and titles of Mapped Installations on the globe
|
||||
// contract (engine.movers, engine.camera, engine.frames, engine.projector),
|
||||
// the same on every engine. The bounded cohort is the named areas, largest
|
||||
// first, with the selected site first: a 7 px disc in its class colour with
|
||||
// a 1 px black outline, always on top, hidden behind the globe's horizon
|
||||
// and for a selected site that is not a named area. The shared overlay
|
||||
// (overlayHost) projects and declutters the titles; a camera move only
|
||||
// re-tests the discs.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
MILITARY_POINT_CAP,
|
||||
} from '../../../data/militaryNames.js';
|
||||
import { createInfrastructureOverlayEntry } from '../../../data/infrastructureOverlayEntry.js';
|
||||
import { installationClassLabel, installationTitle } from '../classLabel.js';
|
||||
import { LAYER_ID } from '../policy.js';
|
||||
import {
|
||||
MARKER_OUTLINE_PX,
|
||||
NAMED_MARKER_ORDER,
|
||||
NAMED_MARKER_OUTLINE_COLOR,
|
||||
NAMED_MARKER_SIZE_PX,
|
||||
installationAccent,
|
||||
} from './style.js';
|
||||
|
||||
export const NAMED_MARKER_SET_ID = `${LAYER_ID}-named`;
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ state: object, services: object, parts: object,
|
||||
* overlayHost: object }} context The layer's state, services and parts,
|
||||
* and the shared overlay that paints the titles.
|
||||
*/
|
||||
export function createNamedMarkers(
|
||||
engine,
|
||||
{ state, services, parts, overlayHost },
|
||||
) {
|
||||
let points = null;
|
||||
let entries = [];
|
||||
const markers = new Map();
|
||||
let ordered = [];
|
||||
let shown = false;
|
||||
let shownPoints = 0;
|
||||
const screen = geo.cartesian2.create();
|
||||
const viewProjection = new Array(16).fill(0);
|
||||
const lastViewProjection = new Array(16).fill(Number.NaN);
|
||||
let width = 0,
|
||||
height = 0;
|
||||
let removeChanged = null,
|
||||
removeEnd = null,
|
||||
removeFrame = null;
|
||||
|
||||
/** Writes each disc's visibility and counts those on screen. */
|
||||
function refresh() {
|
||||
if (!state.enabled || !points) return;
|
||||
({ width, height } = engine.host.size());
|
||||
engine.projector.viewProjection(viewProjection);
|
||||
for (let i = 0; i < 16; i++) lastViewProjection[i] = viewProjection[i];
|
||||
shownPoints = 0;
|
||||
let changed = false;
|
||||
for (let index = 0; index < ordered.length; index++) {
|
||||
const marker = ordered[index];
|
||||
const show = Boolean(
|
||||
shown &&
|
||||
marker.named &&
|
||||
engine.projector.horizonVisible(marker.view.position),
|
||||
);
|
||||
if (marker.view.show !== show) {
|
||||
marker.view.show = show;
|
||||
points.set(index, { show });
|
||||
changed = true;
|
||||
}
|
||||
if (!show) continue;
|
||||
const projected = engine.projector.worldToWindow(
|
||||
marker.view.position,
|
||||
screen,
|
||||
);
|
||||
if (
|
||||
projected &&
|
||||
screen.x >= 0 &&
|
||||
screen.y >= 0 &&
|
||||
screen.x < width &&
|
||||
screen.y < height
|
||||
)
|
||||
shownPoints++;
|
||||
}
|
||||
if (changed) points.commit();
|
||||
}
|
||||
|
||||
function publishLabels() {
|
||||
overlayHost.setEntries(LAYER_ID, entries, {
|
||||
// Keep the bounded point cohort available while Contacts owns the window.
|
||||
// The arbiter spends at most 24 slots on currently visible, separated titles.
|
||||
cohortLimit: MILITARY_POINT_CAP,
|
||||
collisionCapacity: MILITARY_LABEL_CAP,
|
||||
maxVisible: MILITARY_LABEL_CAP - (state.selectedId ? 1 : 0),
|
||||
moving: false,
|
||||
visible: state.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function cameraChanged() {
|
||||
refresh();
|
||||
services.render.governorRequestRender('installation-names');
|
||||
}
|
||||
|
||||
// Small pose changes raise no camera event: test the discs before the
|
||||
// frame whose view or canvas changed.
|
||||
function frame() {
|
||||
engine.projector.viewProjection(viewProjection);
|
||||
const size = engine.host.size();
|
||||
let moved = size.width !== width || size.height !== height;
|
||||
for (let i = 0; i < 16 && !moved; i++)
|
||||
moved = viewProjection[i] !== lastViewProjection[i];
|
||||
if (moved) refresh();
|
||||
}
|
||||
|
||||
function enable() {
|
||||
if (removeChanged || removeEnd || removeFrame) return;
|
||||
removeChanged = engine.camera.changed.addEventListener(cameraChanged);
|
||||
removeEnd = engine.camera.moveEnd.addEventListener(cameraChanged);
|
||||
removeFrame = engine.frames.preRender.addEventListener(frame);
|
||||
shown = true;
|
||||
publishLabels();
|
||||
overlayHost.setVisible(LAYER_ID, true);
|
||||
cameraChanged();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
removeChanged?.();
|
||||
removeEnd?.();
|
||||
removeFrame?.();
|
||||
removeChanged = removeEnd = removeFrame = null;
|
||||
shown = false;
|
||||
if (points) {
|
||||
for (let index = 0; index < ordered.length; index++) {
|
||||
ordered[index].view.show = false;
|
||||
points.set(index, { show: false });
|
||||
}
|
||||
points.commit();
|
||||
}
|
||||
overlayHost.clearSource(LAYER_ID);
|
||||
overlayHost.setVisible(LAYER_ID, false);
|
||||
shownPoints = 0;
|
||||
}
|
||||
|
||||
function sync(records) {
|
||||
points ??= engine.movers.createSet({
|
||||
id: NAMED_MARKER_SET_ID,
|
||||
kind: 'point',
|
||||
capacity: MILITARY_POINT_CAP,
|
||||
order: NAMED_MARKER_ORDER,
|
||||
depthTest: 'always',
|
||||
pickable: true,
|
||||
point: {
|
||||
sizePx: NAMED_MARKER_SIZE_PX,
|
||||
outlinePx: MARKER_OUTLINE_PX,
|
||||
outlineColor: NAMED_MARKER_OUTLINE_COLOR,
|
||||
},
|
||||
});
|
||||
shown = state.enabled;
|
||||
const named = records
|
||||
.filter((r) => r.namedArea || r.id === state.selectedId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.id === state.selectedId) -
|
||||
Number(a.id === state.selectedId) || b.areaM2 - a.areaM2,
|
||||
)
|
||||
.slice(0, MILITARY_POINT_CAP);
|
||||
const keep = new Set(named.map((r) => r.id));
|
||||
// A canonical record takes over its alias's disc and title.
|
||||
for (const record of named) {
|
||||
if (markers.has(record.id)) continue;
|
||||
const alias = record.aliasIds?.find(
|
||||
(id) => !keep.has(id) && markers.has(id),
|
||||
);
|
||||
if (!alias) continue;
|
||||
const marker = markers.get(alias);
|
||||
markers.delete(alias);
|
||||
marker.view.id = { installationId: record.id };
|
||||
marker.entry.id = record.id;
|
||||
markers.set(record.id, marker);
|
||||
}
|
||||
for (const id of [...markers.keys()]) if (!keep.has(id)) markers.delete(id);
|
||||
ordered = [];
|
||||
entries = [];
|
||||
for (const record of named) {
|
||||
const position = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
record.pointOnly ? 0 : parts.model.installationSurfaceHeightM(record),
|
||||
);
|
||||
let marker = markers.get(record.id);
|
||||
if (!marker) {
|
||||
marker = {
|
||||
view: { id: { installationId: record.id }, position, show: false },
|
||||
entry: createInfrastructureOverlayEntry({
|
||||
id: record.id,
|
||||
source: LAYER_ID,
|
||||
position,
|
||||
title: installationTitle(record.name),
|
||||
priority: record.areaM2 || 0,
|
||||
accent: installationAccent(record),
|
||||
}),
|
||||
};
|
||||
markers.set(record.id, marker);
|
||||
}
|
||||
marker.view.position = position;
|
||||
marker.named = record.namedArea;
|
||||
marker.entry.variant =
|
||||
record.id === state.selectedId ? 'selected' : 'card';
|
||||
marker.entry.selected = record.id === state.selectedId;
|
||||
marker.entry.details = marker.entry.selected
|
||||
? [
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
]
|
||||
: [];
|
||||
marker.entry.position = position;
|
||||
marker.entry.title = installationTitle(record.name);
|
||||
marker.entry.priority = record.areaM2 || 0;
|
||||
points.set(ordered.length, {
|
||||
position,
|
||||
color: parts.model.colorFor(record).rgba(),
|
||||
pickId: record.id,
|
||||
show: marker.view.show,
|
||||
});
|
||||
entries.push(marker.entry);
|
||||
ordered.push(marker);
|
||||
}
|
||||
points.setCount(ordered.length);
|
||||
points.commit();
|
||||
if (state.enabled) publishLabels();
|
||||
refresh();
|
||||
services.render.governorRequestRender('installation-names');
|
||||
}
|
||||
|
||||
return {
|
||||
sync,
|
||||
/** Each disc: its site id and `{ id, position, show }`. */
|
||||
visit(visitor) {
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const marker = ordered[i];
|
||||
visitor(marker.view.id.installationId, marker.view);
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
enable,
|
||||
hide,
|
||||
stats: () => ({
|
||||
namedMarkers: markers.size,
|
||||
pointsOnScreen: shownPoints,
|
||||
labelsOnScreen: state.enabled
|
||||
? overlayHost.getDiagnostics?.().paintedBySource[LAYER_ID] || 0
|
||||
: 0,
|
||||
labelCap: MILITARY_LABEL_CAP,
|
||||
}),
|
||||
destroy() {
|
||||
hide();
|
||||
ordered = [];
|
||||
points?.destroy();
|
||||
points = null;
|
||||
entries = [];
|
||||
markers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,638 +0,0 @@
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { createInfrastructureOverlayEntry } from '../../../data/infrastructureOverlayEntry.js';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
MILITARY_POINT_CAP,
|
||||
} from '../../../data/militaryNames.js';
|
||||
import { installationClassLabel, installationTitle } from '../classLabel.js';
|
||||
import { COLOR_BY_CLASS, LAYER_ID, MAX_RENDERED } from '../policy.js';
|
||||
import {
|
||||
createInstallationDrapedMesh,
|
||||
createInstallationOutlineDrapedMesh,
|
||||
} from './navaraGeometry.js';
|
||||
import { batchInstallationOutlines } from './navaraOutlineBatch.js';
|
||||
import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js';
|
||||
|
||||
const KEY = 'gevInstallationsDrape';
|
||||
const BLACK = [0, 0, 0, 0.8];
|
||||
const IDLE_SHADER = /* glsl */ `
|
||||
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
|
||||
outputColor=inputColor;
|
||||
}
|
||||
`;
|
||||
|
||||
const drapeEffect = defineGroundEffect({
|
||||
key: KEY,
|
||||
name: 'GevInstallationDrape',
|
||||
fragmentShader: IDLE_SHADER,
|
||||
insertAfter: ['mrt'],
|
||||
update() {
|
||||
const mrt = this.find('mrt');
|
||||
this.owner.globeNormal.value = mrt?.globeNormalBuffer ?? null;
|
||||
this.owner.globeDepth.value = mrt?.globeDepthBuffer ?? null;
|
||||
const scene = this.ctx.scenes.draped;
|
||||
this.owner.scene = scene;
|
||||
for (const mesh of this.owner.meshes)
|
||||
if (mesh.parent !== scene) scene.add(mesh);
|
||||
},
|
||||
destroy() {
|
||||
for (const mesh of this.owner.meshes) {
|
||||
if (mesh.parent) mesh.parent.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
}
|
||||
this.owner.meshes.clear();
|
||||
this.owner.scene = null;
|
||||
},
|
||||
});
|
||||
|
||||
function createPointSet(engine, { id, sizePx, capacity, order }) {
|
||||
return engine.movers.createSet({
|
||||
id,
|
||||
kind: 'point',
|
||||
capacity,
|
||||
order,
|
||||
depthTest: 'normal',
|
||||
pickable: true,
|
||||
point: { sizePx, outlinePx: 1, outlineColor: BLACK },
|
||||
});
|
||||
}
|
||||
|
||||
export function createInstallationsNavaraRenderer(
|
||||
engine,
|
||||
{
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
overlayHost,
|
||||
view,
|
||||
EffectDesc,
|
||||
NavaraEffect,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
},
|
||||
) {
|
||||
drapeEffect.register(view, { EffectDesc, NavaraEffect });
|
||||
|
||||
const layerState = state;
|
||||
const owner = {
|
||||
meshes: new Set(),
|
||||
scene: null,
|
||||
globeNormal: { value: null },
|
||||
globeDepth: { value: null },
|
||||
};
|
||||
const normalSet = createPointSet(engine, {
|
||||
id: `${LAYER_ID}-normal`,
|
||||
sizePx: 9,
|
||||
capacity: MAX_RENDERED + 1,
|
||||
order: 10,
|
||||
});
|
||||
const selectedSet = createPointSet(engine, {
|
||||
id: `${LAYER_ID}-selected`,
|
||||
sizePx: 13,
|
||||
capacity: 1,
|
||||
order: 11,
|
||||
});
|
||||
const namedSet = createPointSet(engine, {
|
||||
id: `${LAYER_ID}-named`,
|
||||
sizePx: 7,
|
||||
capacity: MILITARY_POINT_CAP,
|
||||
order: 9,
|
||||
});
|
||||
const contextObjects = new Map();
|
||||
const namedRecords = new Map();
|
||||
const selectionMeshes = new Set();
|
||||
const outlineMeshes = new Set();
|
||||
let outlineRecords = [],
|
||||
outlineWidthM = null;
|
||||
let drapeHandle = null;
|
||||
let visible = false;
|
||||
let fillRecord = null;
|
||||
let interactionRemove = null;
|
||||
let removeContextListeners = null;
|
||||
let destroyed = false;
|
||||
|
||||
const displayPosition = (record) =>
|
||||
geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
parts.model.installationSurfaceHeightM(record),
|
||||
);
|
||||
const rgba = (record, selected = false) => {
|
||||
const color = parts.model.colorFor(record);
|
||||
return selected
|
||||
? [1, 1, 1, 1]
|
||||
: [color.red, color.green, color.blue, color.alpha];
|
||||
};
|
||||
const schedule = () => engine.render.request('installations-render');
|
||||
|
||||
function syncPlacesCredit(records) {
|
||||
const usesPlaces = records.some((record) =>
|
||||
record.sources?.some((item) => item.name === 'Google Maps Places'),
|
||||
);
|
||||
if (usesPlaces)
|
||||
services.credits?.showGooglePlacesCredit?.(engine, LAYER_ID);
|
||||
else services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID);
|
||||
}
|
||||
|
||||
function replaceOwnedMeshes(previous, next) {
|
||||
for (const mesh of previous) {
|
||||
if (mesh.parent) mesh.parent.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
owner.meshes.delete(mesh);
|
||||
}
|
||||
previous.clear();
|
||||
for (const mesh of next) {
|
||||
previous.add(mesh);
|
||||
owner.meshes.add(mesh);
|
||||
}
|
||||
syncDrapeEffect();
|
||||
}
|
||||
|
||||
function setMeshList(next) {
|
||||
replaceOwnedMeshes(selectionMeshes, next);
|
||||
}
|
||||
|
||||
function syncDrapeEffect() {
|
||||
const show = visible && owner.meshes.size > 0;
|
||||
if (show && !drapeHandle)
|
||||
drapeHandle = view.addEffect({ [KEY]: { owner }, visible: true });
|
||||
else if (drapeHandle) drapeHandle.update({ visible: show });
|
||||
}
|
||||
|
||||
function fillMeshes(record) {
|
||||
const footprints =
|
||||
record.footprints || (record.footprint ? [[record.footprint]] : []);
|
||||
const color = parts.model.colorFor(record).toCssColorString();
|
||||
const meshes = [];
|
||||
for (const rings of footprints) {
|
||||
const mesh = createInstallationDrapedMesh(record, rings, {
|
||||
color,
|
||||
opacity: 0.22,
|
||||
globeNormal: owner.globeNormal,
|
||||
globeDepth: owner.globeDepth,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
});
|
||||
if (mesh) meshes.push(mesh);
|
||||
}
|
||||
return meshes;
|
||||
}
|
||||
|
||||
function clearSelectionFill() {
|
||||
fillRecord = null;
|
||||
setMeshList([]);
|
||||
}
|
||||
|
||||
function syncSelectionFill() {
|
||||
const record =
|
||||
layerState.enabled && layerState.recordById.get(layerState.selectedId);
|
||||
if (record === fillRecord) return;
|
||||
fillRecord = record ?? null;
|
||||
setMeshList(record ? fillMeshes(record) : []);
|
||||
}
|
||||
|
||||
function syncOutlines(records) {
|
||||
const canvasHeight = Math.max(1, view.canvas?.clientHeight || 1);
|
||||
const cameraHeight = Math.max(0, engine.camera.height?.() || 0);
|
||||
const fov = ((view.camera?.raw?.fov || 60) * Math.PI) / 180;
|
||||
const widthM = Math.max(
|
||||
1,
|
||||
(2 * cameraHeight * Math.tan(fov / 2) * 2) / canvasHeight,
|
||||
);
|
||||
const desired = records.filter(
|
||||
(record) => !record.pointOnly && !record.namedArea,
|
||||
);
|
||||
if (
|
||||
outlineWidthM === widthM &&
|
||||
desired.length === outlineRecords.length &&
|
||||
desired.every((record, index) => record === outlineRecords[index])
|
||||
)
|
||||
return;
|
||||
const next = [];
|
||||
for (const record of desired) {
|
||||
const lines =
|
||||
record.outlineLines || (record.footprint ? [record.footprint] : []);
|
||||
const meshes = lines
|
||||
.map((line) =>
|
||||
createInstallationOutlineDrapedMesh(record, line, {
|
||||
color: parts.model.colorFor(record).toCssColorString(),
|
||||
widthM,
|
||||
globeNormal: owner.globeNormal,
|
||||
globeDepth: owner.globeDepth,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
}),
|
||||
)
|
||||
.filter(Boolean);
|
||||
next.push(...meshes);
|
||||
}
|
||||
replaceOwnedMeshes(outlineMeshes, batchInstallationOutlines(next));
|
||||
outlineRecords = desired;
|
||||
outlineWidthM = widthM;
|
||||
}
|
||||
|
||||
function clearOutlines() {
|
||||
replaceOwnedMeshes(outlineMeshes, []);
|
||||
outlineRecords = [];
|
||||
outlineWidthM = null;
|
||||
}
|
||||
|
||||
function registerContext(record) {
|
||||
let context = contextObjects.get(record.id);
|
||||
if (!context) {
|
||||
context = {};
|
||||
contextObjects.set(record.id, context);
|
||||
}
|
||||
context.gevContextId = record.id;
|
||||
context.gevLabelModel = {
|
||||
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
],
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
};
|
||||
services.context.registerEntityContext(context, {
|
||||
id: record.id,
|
||||
layerId: LAYER_ID,
|
||||
layerName:
|
||||
record.kind === 'place_candidate'
|
||||
? 'Military Site Search Candidates'
|
||||
: 'Mapped Military Installations',
|
||||
source: parts.model.installationSourceLabel(record),
|
||||
label: record.name,
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
class: record.class,
|
||||
memberNames: record.memberNames || [],
|
||||
primaryType: record.primaryType || null,
|
||||
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
|
||||
validation: record.validation,
|
||||
retrievedAt: record.retrievedAt,
|
||||
},
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
function renderRecords({ claimSelection = false } = {}) {
|
||||
if (destroyed) return;
|
||||
const selectedContext = services.context.getSelectedEntityContext();
|
||||
if (
|
||||
!claimSelection &&
|
||||
layerState.selectedId &&
|
||||
selectedContext?.id !== layerState.selectedId
|
||||
)
|
||||
layerState.selectedId = null;
|
||||
if (
|
||||
layerState.selectedId &&
|
||||
!layerState.recordById.has(layerState.selectedId)
|
||||
) {
|
||||
const replacement = layerState.records.find((record) =>
|
||||
record.aliasIds?.includes(layerState.selectedId),
|
||||
);
|
||||
layerState.selectedId = replacement?.id ?? null;
|
||||
}
|
||||
|
||||
const records = layerState.records.filter(
|
||||
(record) => !record.pointOnly && !record.namedArea,
|
||||
);
|
||||
const hasOsm = layerState.records.some((record) =>
|
||||
record.sources?.some((item) => item.name === 'OpenStreetMap'),
|
||||
);
|
||||
if (hasOsm)
|
||||
services.credits?.showOsmCredit?.(engine, LAYER_ID, {
|
||||
openMapTiles: layerState.records.some((record) =>
|
||||
record.sources?.some(
|
||||
(item) =>
|
||||
item.name === 'OpenStreetMap' && item.id?.startsWith('tile:'),
|
||||
),
|
||||
),
|
||||
});
|
||||
else services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
syncPlacesCredit(layerState.records);
|
||||
const normal = records
|
||||
.filter((record) => record.id !== layerState.selectedId)
|
||||
.slice(0, MAX_RENDERED);
|
||||
const selected = layerState.records.find(
|
||||
(record) => record.id === layerState.selectedId && !record.pointOnly,
|
||||
);
|
||||
normalSet.setCount(normal.length);
|
||||
for (let index = 0; index < normal.length; index++) {
|
||||
const record = normal[index];
|
||||
normalSet.set(index, {
|
||||
position: displayPosition(record),
|
||||
color: rgba(record),
|
||||
pickId: record.id,
|
||||
});
|
||||
}
|
||||
selectedSet.setCount(selected ? 1 : 0);
|
||||
if (selected)
|
||||
selectedSet.set(0, {
|
||||
position: displayPosition(selected),
|
||||
color: rgba(selected, true),
|
||||
pickId: selected.id,
|
||||
});
|
||||
normalSet.commit();
|
||||
selectedSet.commit();
|
||||
|
||||
const retainIds = new Set(
|
||||
[...normal, ...(selected ? [selected] : [])].map((r) => r.id),
|
||||
);
|
||||
for (const record of [...normal, ...(selected ? [selected] : [])]) {
|
||||
const entity = registerContext(record);
|
||||
if (
|
||||
record.id === layerState.selectedId &&
|
||||
(claimSelection || !selectedContext)
|
||||
)
|
||||
services.context.selectEntityContext(entity);
|
||||
}
|
||||
services.context.removeEntityContextsForLayer(LAYER_ID, { retainIds });
|
||||
parts.namedMarkers.sync(layerState.records);
|
||||
syncOutlines([...normal, ...(selected ? [selected] : [])]);
|
||||
syncSelectionFill();
|
||||
schedule();
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
if (!layerState.recordById.has(id)) return false;
|
||||
layerState.selectedId = id;
|
||||
renderRecords({ claimSelection: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function deselect() {
|
||||
if (!layerState.selectedId) return;
|
||||
layerState.selectedId = null;
|
||||
services.context.clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
renderRecords();
|
||||
}
|
||||
|
||||
const namedMarkers = {
|
||||
enable() {
|
||||
namedSet.setCount(namedRecords.size);
|
||||
overlayHost.setVisible(LAYER_ID, true);
|
||||
},
|
||||
hide() {
|
||||
namedSet.setCount(0);
|
||||
namedSet.commit();
|
||||
overlayHost.clearSource(LAYER_ID);
|
||||
overlayHost.setVisible(LAYER_ID, false);
|
||||
},
|
||||
sync(records) {
|
||||
namedRecords.clear();
|
||||
const named = records
|
||||
.filter((record) => record.namedArea || record.pointOnly)
|
||||
.sort((a, b) => b.areaM2 - a.areaM2)
|
||||
.slice(0, MILITARY_POINT_CAP);
|
||||
const entries = [];
|
||||
for (let index = 0; index < named.length; index++) {
|
||||
const record = named[index];
|
||||
namedRecords.set(record.id, record);
|
||||
const position = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
record.pointOnly ? 0 : parts.model.installationSurfaceHeightM(record),
|
||||
);
|
||||
namedSet.set(index, {
|
||||
position,
|
||||
color: rgba(record),
|
||||
pickId: record.id,
|
||||
});
|
||||
const entry = createInfrastructureOverlayEntry({
|
||||
id: record.id,
|
||||
source: LAYER_ID,
|
||||
position,
|
||||
title: record.name,
|
||||
priority: record.areaM2 || 0,
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
});
|
||||
const selected = record.id === layerState.selectedId;
|
||||
entry.variant = selected ? 'selected' : 'card';
|
||||
entry.selected = selected;
|
||||
entry.details = selected
|
||||
? [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
]
|
||||
: [];
|
||||
entries.push(entry);
|
||||
}
|
||||
namedSet.setCount(visible ? named.length : 0);
|
||||
namedSet.commit();
|
||||
overlayHost.setEntries(LAYER_ID, entries, {
|
||||
cohortLimit: MILITARY_POINT_CAP,
|
||||
collisionCapacity: MILITARY_LABEL_CAP,
|
||||
maxVisible: MILITARY_LABEL_CAP - (layerState.selectedId ? 1 : 0),
|
||||
moving: false,
|
||||
visible,
|
||||
});
|
||||
},
|
||||
visit(visitor) {
|
||||
for (const record of namedRecords.values())
|
||||
visitor(record.id, {
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
),
|
||||
});
|
||||
},
|
||||
stats: () => ({
|
||||
namedMarkers: namedRecords.size,
|
||||
pointsOnScreen: 0,
|
||||
labelsOnScreen: 0,
|
||||
labelCap: MILITARY_LABEL_CAP,
|
||||
}),
|
||||
destroy() {
|
||||
this.hide();
|
||||
namedRecords.clear();
|
||||
namedSet.destroy();
|
||||
},
|
||||
};
|
||||
|
||||
const rendering = {
|
||||
installationSurfaceHeightM: parts.model.installationSurfaceHeightM,
|
||||
clearSelectionFill,
|
||||
syncSelectionFill,
|
||||
clearRendered() {
|
||||
normalSet.setCount(0);
|
||||
selectedSet.setCount(0);
|
||||
normalSet.commit();
|
||||
selectedSet.commit();
|
||||
clearOutlines();
|
||||
clearSelectionFill();
|
||||
services.context.removeEntityContextsForLayer(LAYER_ID);
|
||||
contextObjects.clear();
|
||||
},
|
||||
renderableRecords() {
|
||||
const records = layerState.records
|
||||
.filter((record) => !record.pointOnly && !record.namedArea)
|
||||
.slice(0, MAX_RENDERED);
|
||||
const selected = layerState.recordById.get(layerState.selectedId);
|
||||
return selected && !records.some((record) => record.id === selected.id)
|
||||
? [...records, selected]
|
||||
: records;
|
||||
},
|
||||
renderRecords,
|
||||
warmInstallationFloors(records) {
|
||||
const cold = records
|
||||
.filter(
|
||||
(record) =>
|
||||
services.ground.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
) == null,
|
||||
)
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
services.anchors.warmFireAnchorFloors(cold).then(() => {
|
||||
if (!layerState.enabled || destroyed) return;
|
||||
if (
|
||||
cold.some(
|
||||
(point) =>
|
||||
services.ground.cachedGroundFloor(point.lat, point.lon) != null,
|
||||
)
|
||||
)
|
||||
renderRecords();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const selection = {
|
||||
selectRecord,
|
||||
installInteraction() {
|
||||
if (interactionRemove) return;
|
||||
const dispatcher = selectionDispatcherFor(engine);
|
||||
if (!dispatcher)
|
||||
throw new Error('Installation selection dispatcher unavailable');
|
||||
interactionRemove = dispatcher.register(LAYER_ID, {
|
||||
owns: (id) => layerState.recordById.has(id),
|
||||
onSelect: selectRecord,
|
||||
onEmptyClick: deselect,
|
||||
});
|
||||
layerState.clickHandler = { destroy: interactionRemove };
|
||||
const events = [
|
||||
'gev:entity-selected',
|
||||
'gev:entity-selection-cleared',
|
||||
'gev:awareness-subject-selected',
|
||||
];
|
||||
const sync = (event) => {
|
||||
if (!layerState.enabled) return;
|
||||
const selected =
|
||||
event?.type === 'gev:awareness-subject-selected'
|
||||
? event.detail
|
||||
: services.context.getSelectedEntityContext();
|
||||
const next =
|
||||
selected?.layerId === LAYER_ID &&
|
||||
layerState.recordById.has(selected.id)
|
||||
? selected.id
|
||||
: null;
|
||||
if (next === layerState.selectedId) return;
|
||||
layerState.selectedId = next;
|
||||
renderRecords();
|
||||
};
|
||||
for (const event of events)
|
||||
globalThis.window?.addEventListener?.(event, sync);
|
||||
removeContextListeners = () => {
|
||||
for (const event of events)
|
||||
globalThis.window?.removeEventListener?.(event, sync);
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
interactionRemove?.();
|
||||
interactionRemove = null;
|
||||
removeContextListeners?.();
|
||||
removeContextListeners = null;
|
||||
},
|
||||
};
|
||||
|
||||
const lifecycle = {
|
||||
methods: {
|
||||
init(viewer, { engine: initEngine = engine } = {}) {
|
||||
layerState.viewer = viewer;
|
||||
layerState.engine = initEngine;
|
||||
const cameraChanged = () => {
|
||||
if (!layerState.contextAnchor) parts.viewport.scheduleLoad();
|
||||
};
|
||||
layerState.moveEndRemove =
|
||||
engine.camera.moveEnd.addEventListener(cameraChanged);
|
||||
layerState.changedRemove =
|
||||
engine.camera.changed.addEventListener(cameraChanged);
|
||||
selection.installInteraction(engine);
|
||||
},
|
||||
enable() {
|
||||
visible = true;
|
||||
layerState.enabled = true;
|
||||
for (const set of [normalSet, selectedSet]) set.setCount(0);
|
||||
normalSet.commit();
|
||||
selectedSet.commit();
|
||||
namedMarkers.enable();
|
||||
namedMarkers.sync(layerState.records || []);
|
||||
services.picking.registerPickOwner(LAYER_ID, (id) =>
|
||||
layerState.recordById.has(id),
|
||||
);
|
||||
renderRecords({ claimSelection: Boolean(layerState.selectedId) });
|
||||
},
|
||||
disable() {
|
||||
visible = false;
|
||||
layerState.enabled = false;
|
||||
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
syncPlacesCredit([]);
|
||||
namedMarkers.hide();
|
||||
layerState.contextAnchor = null;
|
||||
layerState.contextPosition = null;
|
||||
layerState.coverage = { kind: 'viewport' };
|
||||
layerState.cameraLoadKey = null;
|
||||
layerState.cameraLoadOwner = null;
|
||||
services.picking.unregisterPickOwner(LAYER_ID);
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
clearTimeout(layerState.timer);
|
||||
layerState.abort?.abort();
|
||||
layerState.abort = null;
|
||||
layerState.loading = false;
|
||||
normalSet.setCount(0);
|
||||
selectedSet.setCount(0);
|
||||
normalSet.commit();
|
||||
selectedSet.commit();
|
||||
clearOutlines();
|
||||
clearSelectionFill();
|
||||
services.context.clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
layerState.selectedId = null;
|
||||
parts.ingestion.setInstallationStatus('idle');
|
||||
},
|
||||
destroy() {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
this.detach();
|
||||
},
|
||||
detach() {
|
||||
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
syncPlacesCredit([]);
|
||||
services.picking.unregisterPickOwner(LAYER_ID);
|
||||
layerState.moveEndRemove?.();
|
||||
layerState.changedRemove?.();
|
||||
layerState.moveEndRemove = layerState.changedRemove = null;
|
||||
selection.destroy();
|
||||
rendering.clearRendered();
|
||||
namedMarkers.destroy();
|
||||
drapeHandle?.delete();
|
||||
drapeHandle = null;
|
||||
normalSet.destroy();
|
||||
selectedSet.destroy();
|
||||
owner.scene = null;
|
||||
layerState.viewer = null;
|
||||
layerState.engine = null;
|
||||
destroyed = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { lifecycle, namedMarkers, rendering, selection };
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Mesh } from 'three';
|
||||
import { setLocale } from '../../../i18n/index.js';
|
||||
import { installationClassLabel, installationTitle } from '../classLabel.js';
|
||||
import { createInstallationsNavaraRenderer } from './navara.js';
|
||||
|
||||
test('selected installation fill is reused and removed with its scene resources', () => {
|
||||
const record = {
|
||||
id: 'osm:way:12',
|
||||
longitude: 179.9,
|
||||
latitude: 70,
|
||||
class: 'military_land',
|
||||
footprint: [
|
||||
[179.88, 69.99],
|
||||
[179.92, 69.99],
|
||||
[179.92, 70.01],
|
||||
[179.88, 70.01],
|
||||
],
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
records: [record],
|
||||
recordById: new Map([[record.id, record]]),
|
||||
selectedId: null,
|
||||
};
|
||||
const color = {
|
||||
red: 0.6,
|
||||
green: 0.6,
|
||||
blue: 0.6,
|
||||
alpha: 1,
|
||||
toCssColorString: () => '#9ca6b0',
|
||||
};
|
||||
const contexts = new Map();
|
||||
let selectedContext = null;
|
||||
const services = {
|
||||
context: {
|
||||
getSelectedEntityContext: () => selectedContext,
|
||||
registerEntityContext: (entity, value) => contexts.set(value.id, entity),
|
||||
selectEntityContext: (entity) => {
|
||||
selectedContext = {
|
||||
id: entity.gevContextId,
|
||||
layerId: 'military-installations',
|
||||
};
|
||||
},
|
||||
removeEntityContextsForLayer() {},
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
},
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
};
|
||||
const sets = [];
|
||||
const engine = {
|
||||
movers: {
|
||||
createSet(options) {
|
||||
const set = {
|
||||
options,
|
||||
setCount() {},
|
||||
set() {},
|
||||
commit() {},
|
||||
destroy() {},
|
||||
};
|
||||
sets.push(set);
|
||||
return set;
|
||||
},
|
||||
},
|
||||
camera: { height: () => 1_000 },
|
||||
render: { request() {} },
|
||||
};
|
||||
let effectConfig;
|
||||
let deletedEffects = 0;
|
||||
const view = {
|
||||
canvas: { clientHeight: 800 },
|
||||
camera: { raw: { fov: 60 } },
|
||||
registerEffect() {},
|
||||
addEffect(config) {
|
||||
effectConfig = config;
|
||||
return {
|
||||
update() {},
|
||||
delete() {
|
||||
deletedEffects++;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
let meshCount = 0;
|
||||
class TestDrapedMesh extends Mesh {
|
||||
constructor(geometry, material) {
|
||||
super(geometry, material);
|
||||
meshCount++;
|
||||
}
|
||||
}
|
||||
class TestEffectDesc {}
|
||||
const parts = {
|
||||
model: {
|
||||
colorFor: () => color,
|
||||
installationSurfaceHeightM: () => 0,
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
},
|
||||
namedMarkers: { sync() {}, enable() {}, hide() {}, destroy() {} },
|
||||
viewport: { clearUnavailableRetry() {} },
|
||||
ingestion: { setInstallationStatus() {} },
|
||||
};
|
||||
const renderer = createInstallationsNavaraRenderer(engine, {
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
source: {},
|
||||
overlayHost: {
|
||||
setVisible() {},
|
||||
setEntries() {},
|
||||
clearSource() {},
|
||||
},
|
||||
view,
|
||||
EffectDesc: TestEffectDesc,
|
||||
NavaraEffect: class {},
|
||||
DrapedMesh: TestDrapedMesh,
|
||||
setupMaterialForDrape() {},
|
||||
});
|
||||
|
||||
renderer.lifecycle.methods.enable();
|
||||
assert.equal(
|
||||
meshCount,
|
||||
1,
|
||||
'the ordinary outline is present before selection',
|
||||
);
|
||||
assert.equal(renderer.selection.selectRecord(record.id), true);
|
||||
assert.equal(meshCount, 2, 'selection adds one fill beside the outline');
|
||||
const mesh = [...effectConfig.gevInstallationsDrape.owner.meshes].find(
|
||||
(item) => !item.userData.installationOutline,
|
||||
);
|
||||
let disposed = false;
|
||||
mesh.geometry.addEventListener('dispose', () => {
|
||||
disposed = true;
|
||||
});
|
||||
const scene = {
|
||||
add(child) {
|
||||
child.parent = this;
|
||||
},
|
||||
remove(child) {
|
||||
child.parent = null;
|
||||
},
|
||||
};
|
||||
scene.add(mesh);
|
||||
renderer.rendering.renderRecords();
|
||||
assert.equal(meshCount, 2, 'an unchanged selection keeps its geometry');
|
||||
|
||||
renderer.rendering.clearSelectionFill();
|
||||
assert.equal(mesh.parent, null);
|
||||
assert.equal(disposed, true);
|
||||
assert.equal(
|
||||
[...effectConfig.gevInstallationsDrape.owner.meshes].some(
|
||||
(item) => item.userData.installationOutline,
|
||||
),
|
||||
true,
|
||||
'clearing selection preserves the ordinary outline',
|
||||
);
|
||||
assert.equal(sets.length, 3);
|
||||
renderer.lifecycle.methods.destroy();
|
||||
assert.equal(deletedEffects, 1, 'destroy deletes the public effect handle');
|
||||
assert.equal(effectConfig.gevInstallationsDrape.owner.meshes.size, 0);
|
||||
});
|
||||
|
||||
test('the card label reads in the UI language, as on Cesium', () => {
|
||||
const record = {
|
||||
id: 'osm:node:4',
|
||||
longitude: 10,
|
||||
latitude: 50,
|
||||
class: 'naval_base',
|
||||
name: 'Firing range',
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
records: [record],
|
||||
recordById: new Map([[record.id, record]]),
|
||||
selectedId: null,
|
||||
};
|
||||
const contexts = new Map();
|
||||
const set = { setCount() {}, set() {}, commit() {}, destroy() {} };
|
||||
const renderer = createInstallationsNavaraRenderer(
|
||||
{
|
||||
movers: { createSet: () => set },
|
||||
camera: { height: () => 1_000 },
|
||||
render: { request() {} },
|
||||
},
|
||||
{
|
||||
state,
|
||||
services: {
|
||||
context: {
|
||||
getSelectedEntityContext: () => null,
|
||||
registerEntityContext: (entity, value) =>
|
||||
contexts.set(value.id, entity),
|
||||
selectEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
},
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
},
|
||||
parts: {
|
||||
model: {
|
||||
colorFor: () => ({
|
||||
red: 0.6,
|
||||
green: 0.6,
|
||||
blue: 0.6,
|
||||
alpha: 1,
|
||||
toCssColorString: () => '#9ca6b0',
|
||||
}),
|
||||
installationSurfaceHeightM: () => 0,
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
},
|
||||
namedMarkers: { sync() {}, enable() {}, hide() {}, destroy() {} },
|
||||
viewport: { clearUnavailableRetry() {} },
|
||||
ingestion: { setInstallationStatus() {} },
|
||||
},
|
||||
source: {},
|
||||
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
|
||||
view: {
|
||||
canvas: { clientHeight: 800 },
|
||||
camera: { raw: { fov: 60 } },
|
||||
registerEffect() {},
|
||||
addEffect: () => ({ update() {}, delete() {} }),
|
||||
},
|
||||
EffectDesc: class {},
|
||||
NavaraEffect: class {},
|
||||
DrapedMesh: Mesh,
|
||||
setupMaterialForDrape() {},
|
||||
},
|
||||
);
|
||||
try {
|
||||
setLocale('ru');
|
||||
renderer.lifecycle.methods.enable();
|
||||
const label = contexts.get(record.id).gevLabelModel;
|
||||
assert.equal(label.title, installationTitle('Firing range'));
|
||||
assert.notEqual(label.title, 'Firing range');
|
||||
assert.equal(label.details[0], installationClassLabel('naval_base'));
|
||||
assert.notEqual(label.details[0], 'NAVAL BASE');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
renderer.lifecycle.methods.destroy();
|
||||
}
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
import {
|
||||
DoubleSide,
|
||||
ExtrudeGeometry,
|
||||
MeshLambertMaterial,
|
||||
Path,
|
||||
Shape,
|
||||
} from 'three';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { batchStencilDraws } from './navaraStencilBatch.js';
|
||||
|
||||
export const INSTALLATION_DRAPE_DEPTH_M = 40000;
|
||||
|
||||
function localPoint([longitude, latitude], origin, axes) {
|
||||
const point = geo.cartesian3.fromDegrees(longitude, latitude);
|
||||
const delta = geo.cartesian3.subtract(point, origin, geo.cartesian3.create());
|
||||
return {
|
||||
x: geo.cartesian3.dot(delta, axes.east),
|
||||
y: geo.cartesian3.dot(delta, axes.north),
|
||||
};
|
||||
}
|
||||
|
||||
function addRing(path, ring, origin, axes) {
|
||||
const points = ring.map((point) => localPoint(point, origin, axes));
|
||||
if (points.length < 3) return false;
|
||||
path.moveTo(points[0].x, points[0].y);
|
||||
for (const point of points.slice(1)) path.lineTo(point.x, point.y);
|
||||
path.closePath();
|
||||
return true;
|
||||
}
|
||||
|
||||
function offsetLine(line, halfWidth, origin, axes) {
|
||||
const points = [];
|
||||
for (const point of line) {
|
||||
const local = localPoint(point, origin, axes);
|
||||
const previous = points.at(-1);
|
||||
if (
|
||||
!previous ||
|
||||
Math.hypot(local.x - previous.x, local.y - previous.y) >= 0.01
|
||||
)
|
||||
points.push(local);
|
||||
}
|
||||
const closed =
|
||||
points.length > 2 &&
|
||||
Math.hypot(points[0].x - points.at(-1).x, points[0].y - points.at(-1).y) <
|
||||
1;
|
||||
if (closed) points.pop();
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const side = (index, direction) => {
|
||||
const previous = points[index === 0 ? points.length - 1 : index - 1];
|
||||
const current = points[index];
|
||||
const next = points[index === points.length - 1 ? 0 : index + 1];
|
||||
const before = { x: current.x - previous.x, y: current.y - previous.y };
|
||||
const after = { x: next.x - current.x, y: next.y - current.y };
|
||||
const beforeLength = Math.hypot(before.x, before.y);
|
||||
const afterLength = Math.hypot(after.x, after.y);
|
||||
const beforeNormal = {
|
||||
x: -before.y / beforeLength,
|
||||
y: before.x / beforeLength,
|
||||
};
|
||||
const afterNormal = { x: -after.y / afterLength, y: after.x / afterLength };
|
||||
if (!closed && (index === 0 || index === points.length - 1)) {
|
||||
const normal = index === 0 ? afterNormal : beforeNormal;
|
||||
return {
|
||||
x: current.x + normal.x * halfWidth * direction,
|
||||
y: current.y + normal.y * halfWidth * direction,
|
||||
};
|
||||
}
|
||||
const miter = {
|
||||
x: beforeNormal.x + afterNormal.x,
|
||||
y: beforeNormal.y + afterNormal.y,
|
||||
};
|
||||
const length = Math.hypot(miter.x, miter.y);
|
||||
if (length < 1e-6)
|
||||
return {
|
||||
x: current.x + afterNormal.x * halfWidth * direction,
|
||||
y: current.y + afterNormal.y * halfWidth * direction,
|
||||
};
|
||||
miter.x /= length;
|
||||
miter.y /= length;
|
||||
const denominator = miter.x * afterNormal.x + miter.y * afterNormal.y;
|
||||
const distance =
|
||||
halfWidth * Math.min(4, 1 / Math.max(0.25, Math.abs(denominator)));
|
||||
return {
|
||||
x: current.x + miter.x * distance * direction,
|
||||
y: current.y + miter.y * distance * direction,
|
||||
};
|
||||
};
|
||||
|
||||
const outer = points.map((_, index) => side(index, 1));
|
||||
if (!closed) {
|
||||
const inner = points.map((_, index) => side(index, -1)).reverse();
|
||||
return [...outer, ...inner];
|
||||
}
|
||||
return { outer, inner: points.map((_, index) => side(index, -1)) };
|
||||
}
|
||||
|
||||
/** Build a real DrapedMesh volume for one footprint, including its holes. */
|
||||
export function createInstallationDrapedMesh(
|
||||
record,
|
||||
rings,
|
||||
{
|
||||
color,
|
||||
opacity = 0.22,
|
||||
globeNormal,
|
||||
globeDepth,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
},
|
||||
) {
|
||||
const origin = geo.cartesian3.fromDegrees(record.longitude, record.latitude);
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(origin);
|
||||
const axes = {
|
||||
east: geo.cartesian3.create(frame[0], frame[1], frame[2]),
|
||||
north: geo.cartesian3.create(frame[4], frame[5], frame[6]),
|
||||
};
|
||||
const shape = new Shape();
|
||||
if (!addRing(shape, rings[0] || [], origin, axes)) return null;
|
||||
for (const ring of rings.slice(1)) {
|
||||
const hole = new Path();
|
||||
if (addRing(hole, ring, origin, axes)) shape.holes.push(hole);
|
||||
}
|
||||
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
depth: INSTALLATION_DRAPE_DEPTH_M,
|
||||
bevelEnabled: false,
|
||||
steps: 1,
|
||||
curveSegments: 1,
|
||||
});
|
||||
geometry.translate(0, 0, -INSTALLATION_DRAPE_DEPTH_M / 2);
|
||||
const material = new MeshLambertMaterial({
|
||||
color,
|
||||
opacity,
|
||||
transparent: opacity < 1,
|
||||
depthWrite: false,
|
||||
side: DoubleSide,
|
||||
});
|
||||
setupMaterialForDrape?.(material, globeNormal, globeDepth);
|
||||
const mesh = batchStencilDraws(new DrapedMesh(geometry, material));
|
||||
mesh.matrix.fromArray(frame);
|
||||
mesh.matrixAutoUpdate = false;
|
||||
mesh.matrixWorldNeedsUpdate = true;
|
||||
mesh.userData.installationId = record.id;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/** Build a ground-classified, fixed-world-width ribbon around a native outline. */
|
||||
export function createInstallationOutlineDrapedMesh(
|
||||
record,
|
||||
line,
|
||||
{ color, widthM, globeNormal, globeDepth, DrapedMesh, setupMaterialForDrape },
|
||||
) {
|
||||
const origin = geo.cartesian3.fromDegrees(record.longitude, record.latitude);
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(origin);
|
||||
const axes = {
|
||||
east: geo.cartesian3.create(frame[0], frame[1], frame[2]),
|
||||
north: geo.cartesian3.create(frame[4], frame[5], frame[6]),
|
||||
};
|
||||
const outline = offsetLine(line, Math.max(1, widthM / 2), origin, axes);
|
||||
if (!outline) return null;
|
||||
|
||||
const shape = new Shape();
|
||||
const addLocalRing = (path, points) => {
|
||||
if (points.length < 3) return false;
|
||||
path.moveTo(points[0].x, points[0].y);
|
||||
for (const point of points.slice(1)) path.lineTo(point.x, point.y);
|
||||
path.closePath();
|
||||
return true;
|
||||
};
|
||||
if (Array.isArray(outline)) {
|
||||
if (!addLocalRing(shape, outline)) return null;
|
||||
} else {
|
||||
if (!addLocalRing(shape, outline.outer)) return null;
|
||||
const hole = new Path();
|
||||
if (addLocalRing(hole, outline.inner.slice().reverse()))
|
||||
shape.holes.push(hole);
|
||||
}
|
||||
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
depth: INSTALLATION_DRAPE_DEPTH_M,
|
||||
bevelEnabled: false,
|
||||
steps: 1,
|
||||
curveSegments: 1,
|
||||
});
|
||||
geometry.translate(0, 0, -INSTALLATION_DRAPE_DEPTH_M / 2);
|
||||
const material = new MeshLambertMaterial({
|
||||
color,
|
||||
opacity: 0.85,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: DoubleSide,
|
||||
});
|
||||
setupMaterialForDrape?.(material, globeNormal, globeDepth);
|
||||
const mesh = batchStencilDraws(new DrapedMesh(geometry, material));
|
||||
mesh.matrix.fromArray(frame);
|
||||
mesh.matrixAutoUpdate = false;
|
||||
mesh.matrixWorldNeedsUpdate = true;
|
||||
mesh.userData.installationId = record.id;
|
||||
mesh.userData.installationOutline = true;
|
||||
return mesh;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Mesh, MeshLambertMaterial } from 'three';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import {
|
||||
createInstallationDrapedMesh,
|
||||
createInstallationOutlineDrapedMesh,
|
||||
INSTALLATION_DRAPE_DEPTH_M,
|
||||
} from './navaraGeometry.js';
|
||||
|
||||
const record = {
|
||||
id: 'osm:way:12',
|
||||
longitude: 179.9,
|
||||
latitude: 70,
|
||||
};
|
||||
const texture = { value: null };
|
||||
class TestDrapedMesh extends Mesh {
|
||||
constructor(geometry, material) {
|
||||
super(geometry, material);
|
||||
this.drapedEnable = true;
|
||||
}
|
||||
}
|
||||
|
||||
test('installation footprint becomes an engine DrapedMesh with hole and ground refs', () => {
|
||||
const mesh = createInstallationDrapedMesh(
|
||||
record,
|
||||
[
|
||||
[
|
||||
[179.88, 69.99],
|
||||
[179.92, 69.99],
|
||||
[179.92, 70.01],
|
||||
[179.88, 70.01],
|
||||
],
|
||||
[
|
||||
[179.895, 69.995],
|
||||
[179.905, 69.995],
|
||||
[179.905, 70.005],
|
||||
[179.895, 70.005],
|
||||
],
|
||||
],
|
||||
{
|
||||
color: '#9ca6b0',
|
||||
opacity: 0.22,
|
||||
globeNormal: texture,
|
||||
globeDepth: texture,
|
||||
DrapedMesh: TestDrapedMesh,
|
||||
setupMaterialForDrape: (material, normal, depth) => {
|
||||
material.userData.drapeRefs = [normal, depth];
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(mesh instanceof TestDrapedMesh);
|
||||
assert.ok(mesh.material instanceof MeshLambertMaterial);
|
||||
assert.equal(mesh.userData.installationId, record.id);
|
||||
assert.equal(mesh.geometry.parameters.shapes.holes.length, 1);
|
||||
mesh.geometry.computeBoundingBox();
|
||||
assert.equal(
|
||||
mesh.geometry.boundingBox.max.z - mesh.geometry.boundingBox.min.z,
|
||||
INSTALLATION_DRAPE_DEPTH_M,
|
||||
);
|
||||
assert.deepEqual(mesh.material.userData.drapeRefs, [texture, texture]);
|
||||
assert.equal(mesh.matrixAutoUpdate, false);
|
||||
assert.deepEqual(
|
||||
[
|
||||
mesh.matrix.elements[12],
|
||||
mesh.matrix.elements[13],
|
||||
mesh.matrix.elements[14],
|
||||
],
|
||||
Object.values(
|
||||
geo.cartesian3.fromDegrees(record.longitude, record.latitude),
|
||||
),
|
||||
);
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
});
|
||||
|
||||
test('invalid installation footprint rings do not allocate a mesh', () => {
|
||||
assert.equal(
|
||||
createInstallationDrapedMesh(
|
||||
record,
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
],
|
||||
{
|
||||
color: '#9ca6b0',
|
||||
globeNormal: texture,
|
||||
globeDepth: texture,
|
||||
DrapedMesh: TestDrapedMesh,
|
||||
setupMaterialForDrape() {},
|
||||
},
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('ordinary installation outlines are draped ribbons across the date line', () => {
|
||||
const mesh = createInstallationOutlineDrapedMesh(
|
||||
record,
|
||||
[
|
||||
[179.88, 69.99],
|
||||
[-179.92, 69.99],
|
||||
[-179.92, 70.01],
|
||||
[179.88, 70.01],
|
||||
[179.88, 69.99],
|
||||
],
|
||||
{
|
||||
color: '#9ca6b0',
|
||||
widthM: 12,
|
||||
globeNormal: texture,
|
||||
globeDepth: texture,
|
||||
DrapedMesh: TestDrapedMesh,
|
||||
setupMaterialForDrape: (material, normal, depth) => {
|
||||
material.userData.drapeRefs = [normal, depth];
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(mesh instanceof TestDrapedMesh);
|
||||
assert.equal(mesh.userData.installationOutline, true);
|
||||
assert.equal(mesh.geometry.parameters.shapes.holes.length, 1);
|
||||
mesh.geometry.computeBoundingBox();
|
||||
assert.ok(
|
||||
mesh.geometry.boundingBox.max.x - mesh.geometry.boundingBox.min.x < 20_000,
|
||||
);
|
||||
assert.equal(
|
||||
mesh.geometry.boundingBox.max.z - mesh.geometry.boundingBox.min.z,
|
||||
INSTALLATION_DRAPE_DEPTH_M,
|
||||
);
|
||||
assert.deepEqual(mesh.material.userData.drapeRefs, [texture, texture]);
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Matrix4 } from 'three';
|
||||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||
|
||||
// Each DrapedMesh needs three stencil draws. Consecutive, equally coloured
|
||||
// volumes with disjoint XY bounds can share those draws without changing
|
||||
// translucent overlap order. Keep coordinates local to preserve precision.
|
||||
// Consumes the input meshes; the returned meshes own the surviving resources.
|
||||
export function batchInstallationOutlines(meshes) {
|
||||
const groups = [];
|
||||
for (const mesh of meshes) {
|
||||
const group = groups.at(-1);
|
||||
let geometry;
|
||||
if (
|
||||
group &&
|
||||
mesh.material.color.equals(group.mesh.material.color) &&
|
||||
mesh.material.opacity === group.mesh.material.opacity
|
||||
) {
|
||||
const relative = new Matrix4().multiplyMatrices(
|
||||
group.inverse,
|
||||
mesh.matrix,
|
||||
);
|
||||
if (Math.hypot(...relative.elements.slice(12, 15)) <= 100_000) {
|
||||
geometry = mesh.geometry.clone().applyMatrix4(relative);
|
||||
geometry.computeBoundingBox();
|
||||
const b = geometry.boundingBox;
|
||||
if (
|
||||
group.bounds.every(
|
||||
(a) =>
|
||||
b.min.x > a.max.x ||
|
||||
b.max.x < a.min.x ||
|
||||
b.min.y > a.max.y ||
|
||||
b.max.y < a.min.y,
|
||||
)
|
||||
) {
|
||||
group.geometries.push(geometry);
|
||||
group.bounds.push(b);
|
||||
group.mesh.userData.installationIds.push(
|
||||
mesh.userData.installationId,
|
||||
);
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
continue;
|
||||
}
|
||||
geometry.dispose();
|
||||
}
|
||||
}
|
||||
mesh.geometry.computeBoundingBox();
|
||||
mesh.userData.installationIds = [mesh.userData.installationId];
|
||||
groups.push({
|
||||
mesh,
|
||||
inverse: mesh.matrix.clone().invert(),
|
||||
geometries: [mesh.geometry],
|
||||
bounds: [mesh.geometry.boundingBox],
|
||||
});
|
||||
}
|
||||
return groups.map(({ mesh, geometries }) => {
|
||||
if (geometries.length > 1) {
|
||||
mesh.geometry = mergeGeometries(geometries);
|
||||
for (const geometry of geometries) geometry.dispose();
|
||||
}
|
||||
return mesh;
|
||||
});
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BoxGeometry, Mesh, MeshBasicMaterial, Vector3 } from 'three';
|
||||
import { batchInstallationOutlines } from './navaraOutlineBatch.js';
|
||||
|
||||
function volume(id, x, { color = '#ff8800', opacity = 0.85, y = 0 } = {}) {
|
||||
const mesh = new Mesh(
|
||||
new BoxGeometry(10, 10, 40000),
|
||||
new MeshBasicMaterial({ color, opacity }),
|
||||
);
|
||||
mesh.matrix.makeTranslation(x, y, 0);
|
||||
mesh.userData.installationId = id;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
test('disjoint equal-colour outlines share a stencil volume without moving their vertices', () => {
|
||||
const meshes = [
|
||||
volume('a', 6_000_000),
|
||||
volume('b', 6_000_100),
|
||||
volume('c', 6_000_200),
|
||||
];
|
||||
const positions = meshes.flatMap((m) => {
|
||||
const a = m.geometry.attributes.position;
|
||||
return Array.from({ length: a.count }, (_, i) =>
|
||||
new Vector3().fromBufferAttribute(a, i).applyMatrix4(m.matrix),
|
||||
);
|
||||
});
|
||||
const geometryDisposals = [],
|
||||
materialDisposals = [];
|
||||
for (const m of meshes) {
|
||||
m.geometry.addEventListener('dispose', () =>
|
||||
geometryDisposals.push(m.userData.installationId),
|
||||
);
|
||||
m.material.addEventListener('dispose', () =>
|
||||
materialDisposals.push(m.userData.installationId),
|
||||
);
|
||||
}
|
||||
const result = batchInstallationOutlines(meshes);
|
||||
assert.equal(result.length, 1);
|
||||
assert.deepEqual(result[0].userData.installationIds, ['a', 'b', 'c']);
|
||||
const a = result[0].geometry.attributes.position;
|
||||
assert.equal(a.count, positions.length);
|
||||
for (let i = 0; i < a.count; i++)
|
||||
assert.ok(
|
||||
new Vector3()
|
||||
.fromBufferAttribute(a, i)
|
||||
.applyMatrix4(result[0].matrix)
|
||||
.distanceTo(positions[i]) < 1e-5,
|
||||
);
|
||||
assert.deepEqual(geometryDisposals.sort(), ['a', 'b', 'c']);
|
||||
assert.deepEqual(materialDisposals.sort(), ['b', 'c']);
|
||||
result[0].geometry.dispose();
|
||||
result[0].material.dispose();
|
||||
assert.deepEqual(materialDisposals.sort(), ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('overlapping volumes, different materials and distant origins retain separate draws and order', () => {
|
||||
for (const second of [
|
||||
volume('b', 5),
|
||||
volume('b', 100, { color: '#00ff00' }),
|
||||
volume('b', 100, { opacity: 0.5 }),
|
||||
volume('b', 100_001),
|
||||
]) {
|
||||
const first = volume('a', 0),
|
||||
result = batchInstallationOutlines([first, second]);
|
||||
assert.deepEqual(result, [first, second]);
|
||||
for (const mesh of result) {
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
const meshes = [
|
||||
volume('a', 0),
|
||||
volume('b', 100, { color: '#00ff00' }),
|
||||
volume('c', 200),
|
||||
];
|
||||
assert.deepEqual(batchInstallationOutlines(meshes), meshes);
|
||||
for (const mesh of meshes) {
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('batch admission compares against every prior volume, including rotated bounds', () => {
|
||||
const meshes = [volume('a', 0), volume('b', 100), volume('c', 0)];
|
||||
meshes[2].matrix.makeRotationZ(Math.PI / 4);
|
||||
const result = batchInstallationOutlines(meshes);
|
||||
assert.equal(result.length, 2);
|
||||
assert.deepEqual(
|
||||
result.map((m) => m.userData.installationIds),
|
||||
[['a', 'b'], ['c']],
|
||||
);
|
||||
for (const mesh of result) {
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
}
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Material, Mesh } from 'three';
|
||||
|
||||
const stateKeys = [
|
||||
'side',
|
||||
'colorWrite',
|
||||
'depthWrite',
|
||||
'depthTest',
|
||||
'stencilWrite',
|
||||
'stencilFunc',
|
||||
'stencilRef',
|
||||
'stencilFuncMask',
|
||||
'stencilWriteMask',
|
||||
'stencilFail',
|
||||
'stencilZFail',
|
||||
'stencilZPass',
|
||||
];
|
||||
|
||||
// Keep Navara's three stencil draws, but submit them together. Three resolves
|
||||
// the entire multisampled MRT after each render(), even for colorWrite=false.
|
||||
// Use the public DrapedMesh.process callback to obtain the SDK's exact states.
|
||||
export function batchStencilDraws(mesh) {
|
||||
const process = mesh.process;
|
||||
if (!process || Array.isArray(mesh.material)) return mesh;
|
||||
const source = mesh.material;
|
||||
let stages = null,
|
||||
hidden = null,
|
||||
version = -1,
|
||||
disposed = false;
|
||||
const dispose = () => {
|
||||
disposed = true;
|
||||
source.removeEventListener('dispose', dispose);
|
||||
for (const stage of stages ?? []) stage.material.dispose();
|
||||
hidden?.dispose();
|
||||
stages = null;
|
||||
};
|
||||
source.addEventListener('dispose', dispose);
|
||||
mesh.process = function (render) {
|
||||
if (disposed) return;
|
||||
if (this.material !== source || this.children.length)
|
||||
return process.call(this, render);
|
||||
let index = 0;
|
||||
process.call(this, () => {
|
||||
if (!stages) {
|
||||
hidden = new Material();
|
||||
hidden.visible = false;
|
||||
stages = Array.from({ length: 3 }, (_, order) => {
|
||||
const stage = new Mesh(this.geometry, source.clone());
|
||||
stage.renderOrder = order;
|
||||
return stage;
|
||||
});
|
||||
}
|
||||
const stage = stages[index++];
|
||||
if (!stage) return;
|
||||
const material = stage.material;
|
||||
if (version !== source.version) {
|
||||
material.copy(source);
|
||||
material.defines = { ...source.defines };
|
||||
material.onBeforeCompile = source.onBeforeCompile;
|
||||
material.customProgramCacheKey = source.customProgramCacheKey;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
for (const key of stateKeys) material[key] = source[key];
|
||||
material.color = source.color;
|
||||
material.emissive = source.emissive;
|
||||
material.opacity = source.opacity;
|
||||
material.visible = source.visible;
|
||||
material.transparent = source.transparent;
|
||||
stage.geometry = this.geometry;
|
||||
stage.frustumCulled = this.frustumCulled;
|
||||
stage.layers.mask = this.layers.mask;
|
||||
});
|
||||
if (index === 0) return;
|
||||
if (index !== 3) return process.call(this, render);
|
||||
version = source.version;
|
||||
this.material = hidden;
|
||||
this.add(...stages);
|
||||
try {
|
||||
render();
|
||||
} finally {
|
||||
this.remove(...stages);
|
||||
this.material = source;
|
||||
}
|
||||
};
|
||||
return mesh;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
AlwaysStencilFunc,
|
||||
BackSide,
|
||||
FrontSide,
|
||||
KeepStencilOp,
|
||||
IncrementWrapStencilOp,
|
||||
DecrementWrapStencilOp,
|
||||
NotEqualStencilFunc,
|
||||
ZeroStencilOp,
|
||||
BufferGeometry,
|
||||
Mesh,
|
||||
MeshLambertMaterial,
|
||||
Group,
|
||||
} from 'three';
|
||||
import { batchStencilDraws } from './navaraStencilBatch.js';
|
||||
|
||||
const keys = [
|
||||
'side',
|
||||
'colorWrite',
|
||||
'depthWrite',
|
||||
'depthTest',
|
||||
'stencilWrite',
|
||||
'stencilFunc',
|
||||
'stencilRef',
|
||||
'stencilFuncMask',
|
||||
'stencilWriteMask',
|
||||
'stencilFail',
|
||||
'stencilZFail',
|
||||
'stencilZPass',
|
||||
];
|
||||
const snapshot = (m) => Object.fromEntries(keys.map((k) => [k, m[k]]));
|
||||
class Drape extends Mesh {
|
||||
enabled() {
|
||||
return this.visible;
|
||||
}
|
||||
process(render) {
|
||||
if (!this.enabled()) return;
|
||||
const m = this.material,
|
||||
saved = snapshot(m);
|
||||
try {
|
||||
Object.assign(m, {
|
||||
side: BackSide,
|
||||
colorWrite: false,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
stencilWrite: true,
|
||||
stencilFunc: AlwaysStencilFunc,
|
||||
stencilFail: KeepStencilOp,
|
||||
stencilZFail: IncrementWrapStencilOp,
|
||||
stencilZPass: KeepStencilOp,
|
||||
});
|
||||
render();
|
||||
m.side = FrontSide;
|
||||
m.stencilZFail = DecrementWrapStencilOp;
|
||||
render();
|
||||
Object.assign(m, {
|
||||
side: BackSide,
|
||||
colorWrite: true,
|
||||
depthTest: false,
|
||||
stencilFunc: NotEqualStencilFunc,
|
||||
stencilFail: ZeroStencilOp,
|
||||
stencilZFail: ZeroStencilOp,
|
||||
stencilZPass: ZeroStencilOp,
|
||||
});
|
||||
render();
|
||||
} finally {
|
||||
Object.assign(m, saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
function fixture() {
|
||||
const material = new MeshLambertMaterial({
|
||||
color: 0x74aaff,
|
||||
opacity: 0.85,
|
||||
transparent: true,
|
||||
});
|
||||
material.defines = { NVR_TEST: 1 };
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.shared = { value: 123 };
|
||||
};
|
||||
material.customProgramCacheKey = () => 'native-drape';
|
||||
return new Drape(new BufferGeometry(), material);
|
||||
}
|
||||
|
||||
test('three native stencil states render in one submission with the same geometry, transforms and shader hooks', () => {
|
||||
const mesh = fixture(),
|
||||
material = mesh.material,
|
||||
expected = [];
|
||||
mesh.process(() => expected.push(snapshot(material)));
|
||||
mesh.position.set(100, 200, 300);
|
||||
mesh.layers.set(2);
|
||||
batchStencilDraws(mesh);
|
||||
let calls = 0;
|
||||
mesh.process(() => {
|
||||
calls++;
|
||||
assert.equal(mesh.material.visible, false);
|
||||
mesh.updateMatrixWorld(true);
|
||||
assert.deepEqual(
|
||||
mesh.children.map((m) => snapshot(m.material)),
|
||||
expected,
|
||||
);
|
||||
for (const [i, stage] of mesh.children.entries()) {
|
||||
assert.equal(stage.renderOrder, i);
|
||||
assert.equal(stage.geometry, mesh.geometry);
|
||||
assert.deepEqual(stage.matrixWorld.elements, mesh.matrixWorld.elements);
|
||||
assert.equal(stage.layers.mask, mesh.layers.mask);
|
||||
assert.equal(stage.material.onBeforeCompile, material.onBeforeCompile);
|
||||
assert.equal(
|
||||
stage.material.customProgramCacheKey(),
|
||||
material.customProgramCacheKey(),
|
||||
);
|
||||
assert.deepEqual(stage.material.defines, material.defines);
|
||||
}
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(mesh.material, material);
|
||||
assert.equal(mesh.children.length, 0);
|
||||
mesh.geometry.dispose();
|
||||
material.dispose();
|
||||
});
|
||||
|
||||
test('stages track replaced batch geometry, shader versions and live colour/opacity without recompiling steady frames', () => {
|
||||
const mesh = batchStencilDraws(fixture()),
|
||||
source = mesh.material;
|
||||
let previous, versions;
|
||||
mesh.process(() => {
|
||||
previous = mesh.children.slice();
|
||||
versions = previous.map((s) => s.material.version);
|
||||
});
|
||||
const old = mesh.geometry;
|
||||
mesh.geometry = new BufferGeometry();
|
||||
old.dispose();
|
||||
source.color.setHex(0xffcc00);
|
||||
source.opacity = 0.22;
|
||||
source.stencilRef = 2;
|
||||
source.stencilFuncMask = 0x0f;
|
||||
source.stencilWriteMask = 0x0f;
|
||||
mesh.process(() => {
|
||||
assert.deepEqual(mesh.children, previous);
|
||||
assert.deepEqual(
|
||||
mesh.children.map((s) => s.material.version),
|
||||
versions,
|
||||
);
|
||||
for (const s of mesh.children) {
|
||||
assert.equal(s.geometry, mesh.geometry);
|
||||
assert.equal(s.material.color, source.color);
|
||||
assert.equal(s.material.opacity, 0.22);
|
||||
assert.equal(s.material.stencilRef, 2);
|
||||
assert.equal(s.material.stencilFuncMask, 0x0f);
|
||||
assert.equal(s.material.stencilWriteMask, 0x0f);
|
||||
}
|
||||
});
|
||||
source.defines.NVR_NORMAL = 1;
|
||||
source.needsUpdate = true;
|
||||
mesh.process(() => {
|
||||
for (const [i, s] of mesh.children.entries()) {
|
||||
assert.equal(s.material.defines.NVR_NORMAL, 1);
|
||||
assert.ok(s.material.version > versions[i]);
|
||||
}
|
||||
});
|
||||
mesh.geometry.dispose();
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
test('render exceptions restore original ownership and stencil state; material disposal releases only owned resources', () => {
|
||||
const mesh = batchStencilDraws(fixture()),
|
||||
source = mesh.material,
|
||||
saved = snapshot(source);
|
||||
let disposed = 0,
|
||||
geometryDisposed = 0;
|
||||
mesh.geometry.addEventListener('dispose', () => geometryDisposed++);
|
||||
assert.throws(
|
||||
() =>
|
||||
mesh.process(() => {
|
||||
mesh.material.addEventListener('dispose', () => disposed++);
|
||||
for (const s of mesh.children)
|
||||
s.material.addEventListener('dispose', () => disposed++);
|
||||
throw new Error('draw failure');
|
||||
}),
|
||||
/draw failure/,
|
||||
);
|
||||
assert.equal(mesh.material, source);
|
||||
assert.equal(mesh.children.length, 0);
|
||||
assert.deepEqual(snapshot(source), saved);
|
||||
source.dispose();
|
||||
assert.equal(disposed, 4);
|
||||
assert.equal(geometryDisposed, 0);
|
||||
mesh.process(() => assert.fail('disposed owner must not allocate or draw'));
|
||||
source.dispose();
|
||||
assert.equal(disposed, 4);
|
||||
mesh.geometry.dispose();
|
||||
});
|
||||
|
||||
test('hidden meshes allocate nothing; meshes with children keep native behavior', () => {
|
||||
const mesh = batchStencilDraws(fixture());
|
||||
mesh.visible = false;
|
||||
mesh.process(() => assert.fail('hidden draw'));
|
||||
assert.equal(mesh.children.length, 0);
|
||||
mesh.visible = true;
|
||||
const child = new Group();
|
||||
mesh.add(child);
|
||||
let calls = 0;
|
||||
mesh.process(() => {
|
||||
calls++;
|
||||
assert.deepEqual(mesh.children, [child]);
|
||||
});
|
||||
assert.equal(calls, 3);
|
||||
mesh.material.dispose();
|
||||
mesh.geometry.dispose();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// The Mapped Installations look on every engine: sizes in CSS pixels,
|
||||
// colours as rgba in 0..1, and the draw orders of the layer's sets. The
|
||||
// class colours are policy.js's COLOR_BY_CLASS (model.js colorFor).
|
||||
|
||||
import { COLOR_BY_CLASS } from '../policy.js';
|
||||
|
||||
/** A site's disc; the selected site's, drawn white over the others. */
|
||||
export const MARKER_SIZE_PX = 9;
|
||||
export const SELECTED_MARKER_SIZE_PX = 13;
|
||||
export const SELECTED_MARKER_COLOR = Object.freeze([1, 1, 1, 1]);
|
||||
/** A named area's disc. */
|
||||
export const NAMED_MARKER_SIZE_PX = 7;
|
||||
export const MARKER_OUTLINE_PX = 1;
|
||||
export const MARKER_OUTLINE_COLOR = Object.freeze([0, 0, 0, 0.8]);
|
||||
export const NAMED_MARKER_OUTLINE_COLOR = Object.freeze([0, 0, 0, 1]);
|
||||
|
||||
/** A site's outlines on the ground, in its class colour. */
|
||||
export const OUTLINE_WIDTH_PX = 2;
|
||||
export const OUTLINE_ALPHA = 0.85;
|
||||
/** The selected site's footprints on the ground, in its class colour. */
|
||||
export const SELECTION_FILL_ALPHA = 0.22;
|
||||
|
||||
// Map data under the moving contacts (src/data/spriteOrder.js); the named
|
||||
// discs over the sites' discs, the selected disc over both.
|
||||
export const MARKER_ORDER = -1;
|
||||
export const NAMED_MARKER_ORDER = -0.75;
|
||||
export const SELECTED_MARKER_ORDER = -0.5;
|
||||
// The outlines over the selection fill.
|
||||
export const SELECTION_FILL_ORDER = 0;
|
||||
export const OUTLINE_ORDER = 1;
|
||||
|
||||
const DEFAULT_ACCENT = '#9ca6b0';
|
||||
|
||||
/** A site's card and title accent: its class colour, in CSS. */
|
||||
export const installationAccent = (record) =>
|
||||
COLOR_BY_CLASS[record?.class] || DEFAULT_ACCENT;
|
||||
@@ -1,12 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import { createSelection } from './render/cesium/selection.js';
|
||||
import { createRendering } from './render/cesium/rendering.js';
|
||||
import { createNamedMarkers } from './render/cesium/namedMarkers.js';
|
||||
import { SELECTION_FILL_SET_ID } from './render/display.js';
|
||||
import { createDisplayHarness } from './render/displayHarness.mjs';
|
||||
import { GROUND_HARNESSES } from '../../testSupport/groundDisplayEngines.mjs';
|
||||
|
||||
function harness(t) {
|
||||
function harness(t, makeHarness = GROUND_HARNESSES[0]) {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.window = new EventTarget();
|
||||
@@ -29,51 +28,8 @@ function harness(t) {
|
||||
],
|
||||
],
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
records: [record],
|
||||
recordById: new Map([[record.id, record]]),
|
||||
renderedKeys: new Map(),
|
||||
dataSource: new Cesium.CustomDataSource(),
|
||||
viewer: {
|
||||
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e6) },
|
||||
scene: {
|
||||
globe: { show: false },
|
||||
canvas: Object.assign(new EventTarget(), {
|
||||
clientWidth: 1280,
|
||||
clientHeight: 800,
|
||||
}),
|
||||
primitives: { add: (p) => p, remove() {} },
|
||||
},
|
||||
},
|
||||
};
|
||||
t.mock.method(
|
||||
Cesium.SceneTransforms,
|
||||
'worldToWindowCoordinates',
|
||||
(_scene, _point, scratch) => {
|
||||
scratch.x = 640;
|
||||
scratch.y = 400;
|
||||
return scratch;
|
||||
},
|
||||
);
|
||||
let labels = [];
|
||||
const services = {
|
||||
context,
|
||||
anchors: {},
|
||||
ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 },
|
||||
render: { governorRequestRender() {} },
|
||||
};
|
||||
const parts = {
|
||||
model: {
|
||||
colorFor: () => Cesium.Color.ORANGE,
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
},
|
||||
};
|
||||
const ctx = {
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
const h = createDisplayHarness(makeHarness, {
|
||||
overlayHost: {
|
||||
setEntries(_id, entries) {
|
||||
labels = entries;
|
||||
@@ -81,33 +37,30 @@ function harness(t) {
|
||||
clearSource() {},
|
||||
setVisible() {},
|
||||
},
|
||||
};
|
||||
parts.rendering = createRendering(ctx);
|
||||
parts.namedMarkers = createNamedMarkers(ctx);
|
||||
const selection = createSelection(ctx);
|
||||
selection.installInteraction(state.viewer);
|
||||
});
|
||||
h.setRecords([record]);
|
||||
h.setPhotoreal(true);
|
||||
h.start();
|
||||
const selection = h.parts.selection;
|
||||
t.after(() => {
|
||||
selection.destroy();
|
||||
state.clickHandler.destroy();
|
||||
parts.namedMarkers.destroy();
|
||||
parts.rendering.clearRendered();
|
||||
h.destroy();
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
});
|
||||
const assertSelected = (selected) => {
|
||||
assert.equal(state.selectedId, selected ? record.id : null);
|
||||
assert.equal(h.state.selectedId, selected ? record.id : null);
|
||||
assert.equal(labels[0].selected, selected);
|
||||
assert.equal(labels[0].variant, selected ? 'selected' : 'card');
|
||||
assert.equal(
|
||||
state.dataSource.entities.values.filter((e) => e.polygon).length,
|
||||
h.set(SELECTION_FILL_SET_ID)?.items.length ?? 0,
|
||||
selected ? 1 : 0,
|
||||
);
|
||||
};
|
||||
assert.equal(selection.selectRecord(record.id), true);
|
||||
assertSelected(true);
|
||||
return { state, selection, assertSelected };
|
||||
return { state: h.state, selection, assertSelected };
|
||||
}
|
||||
|
||||
for (const layerId of ['flights', 'military', 'satellites']) {
|
||||
|
||||
@@ -1,80 +1,134 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import { createRendering } from './render/cesium/rendering.js';
|
||||
import { createLifecycle } from './render/cesium/lifecycle.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import { SELECTION_FILL_SET_ID } from './render/display.js';
|
||||
import { createDisplayHarness } from './render/displayHarness.mjs';
|
||||
import { GROUND_HARNESSES } from '../../testSupport/groundDisplayEngines.mjs';
|
||||
|
||||
function harness() {
|
||||
const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]];
|
||||
const record = { id: 'base', latitude: 30, longitude: -97, name: 'Base', class: 'base', footprints: [[ring, ring.map(([x,y]) => [x+0.001,y+0.001])], [ring]] };
|
||||
const state = { enabled: true, selectedId: 'base', records: [record], recordById: new Map([['base', record]]), renderedKeys: new Map(), dataSource: new C.CustomDataSource(), viewer: { scene: { globe: { show: false } }, camera: {} } };
|
||||
let listener, removed = 0;
|
||||
const noop = () => {};
|
||||
const services = { ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {}, render: { governorRequestRender: noop }, context: { removeEntityContextsForLayer: noop, clearSelectedEntityContextForLayer: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop }, maps: { subscribeMapStack(cb) { listener = cb; return () => { listener = null; removed++; }; } } };
|
||||
const parts = { model: { colorFor: () => C.Color.ORANGE, installationSourceLabel: () => 'OpenStreetMap' }, viewport: { clearUnavailableRetry: noop }, ingestion: { setInstallationStatus: noop } };
|
||||
parts.rendering = createRendering({ state, services, parts });
|
||||
const lifecycle = createLifecycle({ state, services, parts, source: {} }).methods;
|
||||
return { state, record, parts, lifecycle, changeSurface: () => listener(), removed: () => removed };
|
||||
}
|
||||
|
||||
test('selected multipart footprints classify Google tiles with one constant translucent material and holes', () => {
|
||||
const h = harness(), render = h.parts.rendering;
|
||||
render.syncSelectionFill();
|
||||
const fills = h.state.dataSource.entities.values.slice();
|
||||
assert.equal(fills.length, 2);
|
||||
for (const fill of fills) {
|
||||
assert.equal(fill.installationId, 'base');
|
||||
assert.equal(fill.polygon.classificationType.getValue(), C.ClassificationType.CESIUM_3D_TILE);
|
||||
assert.equal(fill.polygon.height, undefined);
|
||||
assert.equal(fill.polygon.extrudedHeight, undefined);
|
||||
assert.equal(fill.polygon.material.isConstant, true);
|
||||
assert.equal(fill.polygon.material.color.getValue().alpha, 0.22);
|
||||
}
|
||||
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
|
||||
assert.equal(fills[0].polygon.material, fills[1].polygon.material);
|
||||
for (let i = 0; i < 100; i++) render.syncSelectionFill();
|
||||
assert.deepEqual(h.state.dataSource.entities.values, fills, 'repaints retain ground geometry');
|
||||
h.state.selectedId = null; render.syncSelectionFill();
|
||||
assert.equal(h.state.dataSource.entities.values.length, 0);
|
||||
});
|
||||
|
||||
test('surface changes reclassify selected geometry and disable tears down fills and subscription', () => {
|
||||
const h = harness();
|
||||
h.lifecycle.enable(); h.parts.rendering.syncSelectionFill();
|
||||
h.state.viewer.scene.globe.show = true; h.changeSurface();
|
||||
assert.ok(h.state.dataSource.entities.values.every(e => e.polygon.classificationType.getValue() === C.ClassificationType.BOTH));
|
||||
h.lifecycle.disable();
|
||||
assert.equal(h.state.dataSource.entities.values.length, 0);
|
||||
assert.equal(h.removed(), 1);
|
||||
h.lifecycle.enable(); h.parts.rendering.syncSelectionFill();
|
||||
assert.equal(h.state.dataSource.entities.values.length, 0, 'reenabling cannot revive a stale selection');
|
||||
h.lifecycle.disable();
|
||||
});
|
||||
|
||||
|
||||
test('geometry rebuild and canonical alias migration preserve shared selection before pruning', async () => {
|
||||
const context = await import('../../data/contextStore.js');
|
||||
function harness(t, makeHarness) {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = new EventTarget();
|
||||
try {
|
||||
const h = harness();
|
||||
const services = { context, render: { governorRequestRender() {} }, ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {} };
|
||||
const rendering = createRendering({ state: h.state, services, parts: h.parts });
|
||||
const ring = [
|
||||
[-97, 30],
|
||||
[-96.99, 30],
|
||||
[-96.99, 30.01],
|
||||
[-97, 30],
|
||||
];
|
||||
const record = {
|
||||
id: 'base',
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
name: 'Base',
|
||||
class: 'base',
|
||||
footprints: [[ring, ring.map(([x, y]) => [x + 0.001, y + 0.001])], [ring]],
|
||||
};
|
||||
const h = createDisplayHarness(makeHarness);
|
||||
h.setRecords([record]);
|
||||
h.state.selectedId = 'base';
|
||||
// Photoreal tiles shown: the globe is hidden.
|
||||
h.setPhotoreal(true);
|
||||
h.parts.lifecycle.methods.init();
|
||||
t.after(() => {
|
||||
h.destroy();
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
const fill = () => h.set(SELECTION_FILL_SET_ID);
|
||||
return {
|
||||
h,
|
||||
record,
|
||||
render: h.parts.rendering,
|
||||
lifecycle: h.parts.lifecycle.methods,
|
||||
fill,
|
||||
};
|
||||
}
|
||||
|
||||
for (const makeHarness of GROUND_HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`selected multipart footprints drape on Google tiles in one constant translucent colour with holes (${name})`, (t) => {
|
||||
const { h, render, fill } = harness(t, makeHarness);
|
||||
h.state.enabled = true;
|
||||
render.syncSelectionFill();
|
||||
const { items, options, commits } = fill();
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(options.drape, 'tiles');
|
||||
for (const item of items) {
|
||||
assert.equal(item.pickId, 'base');
|
||||
assert.equal(item.fill[3], 0.22);
|
||||
assert.equal(item.height, undefined);
|
||||
}
|
||||
assert.equal(
|
||||
items[0].rings.length,
|
||||
2,
|
||||
'the first footprint keeps its hole',
|
||||
);
|
||||
assert.deepEqual(items[0].fill, items[1].fill);
|
||||
if (h.cesium)
|
||||
assert.equal(
|
||||
h.primitives(SELECTION_FILL_SET_ID).fills[0].options.classificationType,
|
||||
Cesium.ClassificationType.CESIUM_3D_TILE,
|
||||
);
|
||||
for (let i = 0; i < 100; i++) render.syncSelectionFill();
|
||||
assert.equal(fill().commits, commits, 'repaints retain ground geometry');
|
||||
h.state.selectedId = null;
|
||||
render.syncSelectionFill();
|
||||
assert.equal(fill().items.length, 0);
|
||||
});
|
||||
|
||||
test(`surface changes re-drape selected geometry and disable tears down fills and subscription (${name})`, (t) => {
|
||||
const { h, lifecycle, render, fill } = harness(t, makeHarness);
|
||||
lifecycle.enable();
|
||||
h.state.selectedId = 'base';
|
||||
render.syncSelectionFill();
|
||||
h.setPhotoreal(false);
|
||||
h.changeMapStack();
|
||||
assert.equal(fill().options.drape, 'both');
|
||||
assert.equal(fill().items.length, 2);
|
||||
if (h.cesium)
|
||||
assert.equal(
|
||||
h.primitives(SELECTION_FILL_SET_ID).fills[0].options.classificationType,
|
||||
Cesium.ClassificationType.BOTH,
|
||||
);
|
||||
lifecycle.disable();
|
||||
assert.equal(fill().items.length, 0);
|
||||
assert.equal(h.mapStackRemoved(), 1);
|
||||
lifecycle.enable();
|
||||
render.syncSelectionFill();
|
||||
assert.equal(
|
||||
fill().items.length,
|
||||
0,
|
||||
'reenabling cannot revive a stale selection',
|
||||
);
|
||||
lifecycle.disable();
|
||||
});
|
||||
|
||||
test(`geometry rebuild and canonical alias migration preserve shared selection before pruning (${name})`, (t) => {
|
||||
const { h, record, render } = harness(t, makeHarness);
|
||||
h.state.enabled = true;
|
||||
const cleared = [];
|
||||
window.addEventListener('gev:entity-selection-cleared', e => { cleared.push(e.detail); h.state.selectedId = null; });
|
||||
rendering.renderRecords({ claimSelection: true });
|
||||
window.addEventListener('gev:entity-selection-cleared', (e) => {
|
||||
cleared.push(e.detail);
|
||||
h.state.selectedId = null;
|
||||
});
|
||||
render.renderRecords({ claimSelection: true });
|
||||
const oldEntity = context.getSelectedEntityContext().entity;
|
||||
h.record.latitude += 0.001;
|
||||
rendering.renderRecords();
|
||||
h.setRecords([{ ...record, latitude: record.latitude + 0.001 }]);
|
||||
render.renderRecords();
|
||||
assert.equal(h.state.selectedId, 'base');
|
||||
assert.equal(context.getSelectedEntityContext().id, 'base');
|
||||
assert.notEqual(context.getSelectedEntityContext().entity, oldEntity);
|
||||
const replacement = { ...h.record, id: 'canonical', aliasIds: ['base'] };
|
||||
h.state.records = [replacement]; h.state.recordById = new Map([['canonical', replacement]]);
|
||||
rendering.renderRecords();
|
||||
const replacement = { ...record, id: 'canonical', aliasIds: ['base'] };
|
||||
h.setRecords([replacement]);
|
||||
render.renderRecords();
|
||||
assert.equal(h.state.selectedId, 'canonical');
|
||||
assert.equal(context.getSelectedEntityContext().id, 'canonical');
|
||||
assert.deepEqual(cleared, [], 'no transient eviction can tear down the selected card or fill');
|
||||
rendering.clearRendered();
|
||||
} finally { if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; }
|
||||
});
|
||||
assert.deepEqual(
|
||||
cleared,
|
||||
[],
|
||||
'no transient eviction can tear down the selected card or fill',
|
||||
);
|
||||
render.clearRendered();
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,6 @@ export function createState({ services }) {
|
||||
Object.assign(state, {
|
||||
viewer: null,
|
||||
engine: null,
|
||||
dataSource: null,
|
||||
enabled: false,
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
@@ -30,7 +29,6 @@ export function createState({ services }) {
|
||||
retryAt: 0,
|
||||
failureReason: null,
|
||||
moveEndRemove: null,
|
||||
clickHandler: null,
|
||||
timer: null,
|
||||
googleSearchRequested: false,
|
||||
/** Contacts subject position; when set, it replaces the camera viewport. */
|
||||
@@ -39,8 +37,6 @@ export function createState({ services }) {
|
||||
contextPosition: null,
|
||||
/** Coverage of the last successful load: viewport, or radius around a subject. */
|
||||
coverage: { kind: 'viewport' },
|
||||
/** Per-record geometry keys of the rendered entities, for in-place updates. */
|
||||
renderedKeys: new Map(),
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createNamedMarkers } from '../layers/installations/render/cesium/namedMarkers.js';
|
||||
import { createNamedMarkers } from '../layers/installations/render/namedMarkers.js';
|
||||
import { rgbaFromCss } from '../layers/shared/color.js';
|
||||
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
|
||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||
import test from 'node:test';
|
||||
@@ -2423,27 +2424,24 @@ test('diagnostics facade preserves the complete binding shape', () => {
|
||||
});
|
||||
|
||||
|
||||
test('installation titles share persistent card arbitration and retain a bounded Contacts cohort', (t) => {
|
||||
const env = installMockEnvironment({ width: 1600, height: 1000 });
|
||||
test('installation titles share persistent card arbitration and retain a bounded Contacts cohort', async (t) => {
|
||||
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
|
||||
const env = installMockEnvironment({ width: 1600, height: 1000, dpr: 1 });
|
||||
const engine = createFakeEngine({ width: 1600, height: 1000, container: env.viewer.container });
|
||||
t.after(() => env.cleanup());
|
||||
const scene = env.viewer.scene;
|
||||
scene.canvas = env.viewer.canvas;
|
||||
scene.preRender = new Cesium.Event();
|
||||
scene.primitives = { add: (value) => value, remove() {} };
|
||||
env.viewer.camera.changed = new Cesium.Event();
|
||||
t.mock.method(Cesium.Cartesian3, 'fromDegrees', (lon, lat) => new Cesium.Cartesian3(lon, lat, 0));
|
||||
t.mock.method(Cesium.EllipsoidalOccluder.prototype, 'isPointVisible', () => true);
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', (_scene, _point, scratch) => {
|
||||
scratch.x = scratch.y = 100; return scratch;
|
||||
});
|
||||
const state = { enabled: true, viewer: env.viewer, contextAnchor: {} };
|
||||
const markers = createNamedMarkers({ state,
|
||||
// Looking straight down on the sites, about two degrees across.
|
||||
let longitude = 0;
|
||||
let height = 200_000;
|
||||
const look = () => engine.fake.setPose({ longitude: Cesium.Math.toRadians(longitude), latitude: 0, height, heading: 0, pitch: -Math.PI / 2, roll: 0 });
|
||||
look();
|
||||
const state = { enabled: true, contextAnchor: {} };
|
||||
const markers = createNamedMarkers(engine, { state,
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts: { model: { colorFor: () => Cesium.Color.ORANGE }, rendering: { installationSurfaceHeightM: () => 0 } },
|
||||
parts: { model: { colorFor: () => rgbaFromCss('#ff8000'), installationSurfaceHeightM: () => 0 } },
|
||||
overlayHost: { setEntries: setOverlayEntries, clearSource: clearOverlaySource,
|
||||
setVisible: setOverlaySourceVisible, getDiagnostics: getWorldOverlayDiagnostics },
|
||||
});
|
||||
initWorldOverlay(env.engine);
|
||||
initWorldOverlay(engine);
|
||||
markers.enable();
|
||||
const records = Array.from({ length: 40 }, (_, i) => ({ id: `site:${i}`, name: `Base ${i}`,
|
||||
namedArea: true, pointOnly: i % 2 === 0, areaM2: 1000 - i,
|
||||
@@ -2454,27 +2452,27 @@ test('installation titles share persistent card arbitration and retain a bounded
|
||||
records[1].longitude = records[0].longitude + 0.03;
|
||||
records[1].latitude = records[0].latitude;
|
||||
markers.sync(records);
|
||||
env.postRender.raise();
|
||||
engine.fake.frame();
|
||||
const source = 'military-installations';
|
||||
env.advanceTime(250); env.postRender.raise();
|
||||
env.advanceTime(250); engine.fake.frame();
|
||||
assert.equal(markers.stats().labelsOnScreen, 24);
|
||||
assert.ok(getOverlayPaintRect(source, 'site:0'));
|
||||
assert.equal(getOverlayPaintRect(source, 'small'), null);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
env.viewer.camera.viewMatrix[12] -= 0.003;
|
||||
if (i > 15) env.viewer.camera.viewMatrix[0] = env.viewer.camera.viewMatrix[5] = 0.03;
|
||||
scene.preRender.raiseEvent(); env.advanceTime(16); env.postRender.raise();
|
||||
longitude -= 0.003;
|
||||
if (i > 15) height = 4_000_000;
|
||||
look(); env.advanceTime(16); engine.fake.frame();
|
||||
assert.ok(markers.stats().labelsOnScreen > 0 && markers.stats().labelsOnScreen <= 24);
|
||||
// Incumbents persist through collisions during a pan, as for Data Centers.
|
||||
if (i < 10) assert.ok(getOverlayPaintRect(source, 'site:0'));
|
||||
}
|
||||
state.enabled = false; markers.hide(); env.postRender.raise();
|
||||
state.enabled = false; markers.hide(); engine.fake.frame();
|
||||
assert.equal(getWorldOverlayDiagnostics().entriesBySource[source], 0);
|
||||
assert.equal(markers.stats().labelsOnScreen, 0);
|
||||
state.enabled = true; markers.enable(); env.postRender.raise();
|
||||
env.advanceTime(250); env.postRender.raise();
|
||||
state.enabled = true; markers.enable(); engine.fake.frame();
|
||||
env.advanceTime(250); engine.fake.frame();
|
||||
assert.ok(markers.stats().labelsOnScreen > 0);
|
||||
markers.destroy(); env.postRender.raise();
|
||||
markers.destroy(); engine.fake.frame();
|
||||
assert.equal(getWorldOverlayDiagnostics().entriesBySource[source], 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -186,9 +186,10 @@ test('distinct named bases separated by one seam quantization step retain separa
|
||||
});
|
||||
|
||||
test('named A+B to B to A+B preserves canonical identity, parent title and selection', async () => {
|
||||
const C = await import('cesium');
|
||||
const { createRendering } =
|
||||
await import('../layers/installations/render/cesium/rendering.js');
|
||||
const { createDisplayHarness } =
|
||||
await import('../layers/installations/render/displayHarness.mjs');
|
||||
const { createFakeGroundHarness } =
|
||||
await import('../testSupport/groundDisplayEngines.mjs');
|
||||
const aliases = new Map();
|
||||
const named = (n, footprint, areaM2) =>
|
||||
record(String(n * 10 + 2), footprint, {
|
||||
@@ -208,36 +209,23 @@ test('named A+B to B to A+B preserves canonical identity, parent title and selec
|
||||
const a = named(1, ring(0, 0, 1, 1), 100),
|
||||
b = named(2, ring(1, 0, 2, 1), 10);
|
||||
let selected = { id: 'osm:military:w1' };
|
||||
const state = {
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
selectedId: selected.id,
|
||||
renderedKeys: new Map(),
|
||||
dataSource: new C.CustomDataSource(),
|
||||
};
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
source: {},
|
||||
parts: {
|
||||
model: {
|
||||
colorFor: () => C.Color.ORANGE,
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
const handles = new Map();
|
||||
const h = createDisplayHarness(createFakeGroundHarness, {
|
||||
context: {
|
||||
getSelectedEntityContext: () => selected,
|
||||
removeEntityContextsForLayer() {},
|
||||
registerEntityContext: (entity, context) =>
|
||||
handles.set(context.id, entity),
|
||||
selectEntityContext: (entity) => {
|
||||
selected = entity;
|
||||
},
|
||||
},
|
||||
services: {
|
||||
ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 },
|
||||
context: {
|
||||
getSelectedEntityContext: () => selected,
|
||||
removeEntityContextsForLayer() {},
|
||||
registerEntityContext() {},
|
||||
selectEntityContext: (entity) => {
|
||||
selected = entity;
|
||||
},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
anchors: {},
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
},
|
||||
});
|
||||
h.parts.lifecycle.methods.init();
|
||||
const { state } = h;
|
||||
state.selectedId = selected.id;
|
||||
const rendering = h.parts.rendering;
|
||||
for (const members of [[a, b], [b], [a, b]]) {
|
||||
state.records = mergeMilitaryFragments(members, aliases);
|
||||
state.recordById = new Map(state.records.map((r) => [r.id, r]));
|
||||
@@ -246,7 +234,10 @@ test('named A+B to B to A+B preserves canonical identity, parent title and selec
|
||||
assert.deepEqual(state.records[0].memberNames, ['Site 2']);
|
||||
rendering.renderRecords();
|
||||
assert.equal(state.selectedId, 'osm:military:w1');
|
||||
assert.ok(state.dataSource.entities.getById(state.selectedId));
|
||||
assert.ok(
|
||||
handles.has(state.selectedId),
|
||||
'the selected site has its handle',
|
||||
);
|
||||
}
|
||||
const [detail] = mergeMilitaryFragments([{ ...b, tileZoom: 12 }], aliases);
|
||||
assert.equal(detail.id, 'osm:military:w2');
|
||||
@@ -255,12 +246,14 @@ test('named A+B to B to A+B preserves canonical identity, parent title and selec
|
||||
'Site 2',
|
||||
'coarse parent metadata cannot override a detail split',
|
||||
);
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test('cold names reconcile selection through enrichment, identical groups at another zoom and wide-point handoff', async () => {
|
||||
const C = await import('cesium');
|
||||
const { createRendering } =
|
||||
await import('../layers/installations/render/cesium/rendering.js');
|
||||
const { createDisplayHarness } =
|
||||
await import('../layers/installations/render/displayHarness.mjs');
|
||||
const { createFakeGroundHarness } =
|
||||
await import('../testSupport/groundDisplayEngines.mjs');
|
||||
const { retainNamedHandoff } =
|
||||
await import('../layers/installations/ingestion.js');
|
||||
const aliases = new Map();
|
||||
@@ -287,47 +280,37 @@ test('cold names reconcile selection through enrichment, identical groups at ano
|
||||
const [warm] = mergeMilitaryFragments(named);
|
||||
let selected = null;
|
||||
const contexts = new Map();
|
||||
const state = {
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
selectedId: null,
|
||||
renderedKeys: new Map(),
|
||||
dataSource: new C.CustomDataSource(),
|
||||
};
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
source: {},
|
||||
parts: {
|
||||
model: {
|
||||
colorFor: () => C.Color.ORANGE,
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
const handles = new Map();
|
||||
const h = createDisplayHarness(createFakeGroundHarness, {
|
||||
context: {
|
||||
getSelectedEntityContext: () => selected,
|
||||
removeEntityContextsForLayer: (_id, { retainIds } = {}) => {
|
||||
for (const id of contexts.keys())
|
||||
if (!retainIds?.has(id)) contexts.delete(id);
|
||||
},
|
||||
},
|
||||
services: {
|
||||
ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 },
|
||||
context: {
|
||||
getSelectedEntityContext: () => selected,
|
||||
removeEntityContextsForLayer: (_id, { retainIds } = {}) => {
|
||||
for (const id of contexts.keys())
|
||||
if (!retainIds?.has(id)) contexts.delete(id);
|
||||
},
|
||||
registerEntityContext: (_entity, context) =>
|
||||
contexts.set(context.id, context),
|
||||
selectEntityContext: (entity) => {
|
||||
selected = contexts.get(entity.id);
|
||||
},
|
||||
registerEntityContext: (entity, context) => {
|
||||
contexts.set(context.id, context);
|
||||
handles.set(context.id, entity);
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
anchors: {},
|
||||
selectEntityContext: (entity) => {
|
||||
selected = contexts.get(entity.id);
|
||||
},
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
},
|
||||
});
|
||||
h.parts.lifecycle.methods.init();
|
||||
const { state } = h;
|
||||
const rendering = h.parts.rendering;
|
||||
const publish = (records, claimSelection = false) => {
|
||||
state.records = records;
|
||||
state.recordById = new Map(records.map((r) => [r.id, r]));
|
||||
rendering.renderRecords({ claimSelection });
|
||||
assert.equal(state.selectedId, records[0].id);
|
||||
assert.equal(selected.id, records[0].id);
|
||||
assert.ok(state.dataSource.entities.getById(state.selectedId));
|
||||
assert.ok(
|
||||
handles.has(state.selectedId),
|
||||
'the selected site has its handle',
|
||||
);
|
||||
assert.ok(contexts.has(selected.id));
|
||||
};
|
||||
const cold = mergeMilitaryFragments(unnamed, aliases);
|
||||
@@ -380,4 +363,5 @@ test('cold names reconcile selection through enrichment, identical groups at ano
|
||||
'identity migration cannot reclaim another layer selection',
|
||||
);
|
||||
assert.equal(selected.id, 'aircraft:other');
|
||||
h.destroy();
|
||||
});
|
||||
Reference in new issue
Block a user