mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Show drone airspace, OSM places, sea marks and ski pistes on Navara, and keep them across engine switches
This commit is contained in:
1 parent
43b4e9348b
commit
843c4a111d
25 files changed
+818
-275
No files matched your search
@@ -3357,6 +3357,7 @@
|
|||||||
"src/app/layers/perimeters.js",
|
"src/app/layers/perimeters.js",
|
||||||
"src/layers/perimeters/index.js",
|
"src/layers/perimeters/index.js",
|
||||||
"src/layers/perimeters/render/cesium.js",
|
"src/layers/perimeters/render/cesium.js",
|
||||||
|
"src/layers/shared/render/cesiumZones.js",
|
||||||
"src/layers/perimeters/source.js",
|
"src/layers/perimeters/source.js",
|
||||||
"src/layers/perimeters/records.js",
|
"src/layers/perimeters/records.js",
|
||||||
"src/layers/perimeters/cards.js",
|
"src/layers/perimeters/cards.js",
|
||||||
@@ -3817,6 +3818,9 @@
|
|||||||
"src/layers/gnss/render/navara.js",
|
"src/layers/gnss/render/navara.js",
|
||||||
"src/layers/perimeters/render/navara.js",
|
"src/layers/perimeters/render/navara.js",
|
||||||
"src/layers/shared/render/navaraPolygons.js",
|
"src/layers/shared/render/navaraPolygons.js",
|
||||||
|
"src/layers/shared/render/navaraZones.js",
|
||||||
|
"src/layers/droneAirspace/render/navara.js",
|
||||||
|
"src/layers/osmPlaces/render/navara.js",
|
||||||
"src/layers/earthquakes/render/navaraGeometry.js",
|
"src/layers/earthquakes/render/navaraGeometry.js",
|
||||||
"src/layers/earthquakes/render/navaraGrid.js",
|
"src/layers/earthquakes/render/navaraGrid.js",
|
||||||
"src/layers/installations/render/navara.js",
|
"src/layers/installations/render/navara.js",
|
||||||
@@ -4314,6 +4318,7 @@
|
|||||||
"src/app/layers/perimeters.js",
|
"src/app/layers/perimeters.js",
|
||||||
"src/layers/perimeters/index.js",
|
"src/layers/perimeters/index.js",
|
||||||
"src/layers/perimeters/render/cesium.js",
|
"src/layers/perimeters/render/cesium.js",
|
||||||
|
"src/layers/shared/render/cesiumZones.js",
|
||||||
"src/layers/perimeters/source.js",
|
"src/layers/perimeters/source.js",
|
||||||
"src/layers/perimeters/records.js",
|
"src/layers/perimeters/records.js",
|
||||||
"src/layers/perimeters/cards.js",
|
"src/layers/perimeters/cards.js",
|
||||||
@@ -5506,6 +5511,7 @@
|
|||||||
"src/geo/math.js",
|
"src/geo/math.js",
|
||||||
"src/layers/perimeters/index.js",
|
"src/layers/perimeters/index.js",
|
||||||
"src/layers/perimeters/render/cesium.js",
|
"src/layers/perimeters/render/cesium.js",
|
||||||
|
"src/layers/shared/render/cesiumZones.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/layers/perimeters/source.js",
|
"src/layers/perimeters/source.js",
|
||||||
@@ -5656,6 +5662,25 @@
|
|||||||
"exports": ["./layers/drone-airspace"],
|
"exports": ["./layers/drone-airspace"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/droneAirspace/index.js",
|
"src/layers/droneAirspace/index.js",
|
||||||
|
"src/geo/index.js",
|
||||||
|
"src/geo/ellipsoid.js",
|
||||||
|
"src/geo/scaleToGeodeticSurface.js",
|
||||||
|
"src/geo/debug.js",
|
||||||
|
"src/geo/ellipsoidGeodesic.js",
|
||||||
|
"src/geo/boundingSphere.js",
|
||||||
|
"src/geo/transforms.js",
|
||||||
|
"src/geo/matrix4.js",
|
||||||
|
"src/geo/cartesian3.js",
|
||||||
|
"src/geo/quaternion.js",
|
||||||
|
"src/geo/math.js",
|
||||||
|
"src/geo/matrix3.js",
|
||||||
|
"src/geo/cartographic.js",
|
||||||
|
"src/geo/cartesian2.js",
|
||||||
|
"src/geo/check.js",
|
||||||
|
"src/geo/developerError.js",
|
||||||
|
"src/geo/simon1994.js",
|
||||||
|
"src/geo/julianDate.js",
|
||||||
|
"src/layers/shared/render/cesiumZones.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/layers/droneAirspace/source.js",
|
"src/layers/droneAirspace/source.js",
|
||||||
"src/layers/droneAirspace/records.js",
|
"src/layers/droneAirspace/records.js",
|
||||||
|
|||||||
@@ -254,15 +254,14 @@ export function createApplicationCatalog({
|
|||||||
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
// Cesium only, and without attachRenderer: built for each catalog and
|
|
||||||
// destroyed when its globe leaves, so never a session model (as are
|
|
||||||
// osm-places and the OSM overlays).
|
|
||||||
[
|
[
|
||||||
'drone-airspace',
|
'drone-airspace',
|
||||||
() =>
|
() =>
|
||||||
|
sessionModel('drone-airspace', () =>
|
||||||
createApplicationDroneAirspace({
|
createApplicationDroneAirspace({
|
||||||
source: sources['drone-airspace'],
|
source: sources['drone-airspace'],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
@@ -352,7 +351,10 @@ export function createApplicationCatalog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
['osm-places', () => createApplicationOsmPlaces()],
|
[
|
||||||
|
'osm-places',
|
||||||
|
() => sessionModel('osm-places', () => createApplicationOsmPlaces()),
|
||||||
|
],
|
||||||
['ais-live-vessels', vessels],
|
['ais-live-vessels', vessels],
|
||||||
['military-installations', installations],
|
['military-installations', installations],
|
||||||
[
|
[
|
||||||
@@ -404,7 +406,10 @@ export function createApplicationCatalog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[OSM_OVERLAYS.map(({ id }) => id), () => createOsmOverlayLayers()],
|
[
|
||||||
|
OSM_OVERLAYS.map(({ id }) => id),
|
||||||
|
() => sessionModel('osm-overlays', () => createOsmOverlayLayers()),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
['local-datacenters', 'local-dams'],
|
['local-datacenters', 'local-dams'],
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -251,15 +251,7 @@ test('the catalog per engine: Cesium as before, Navara only what it can show', a
|
|||||||
assert.equal(subset.get('military'), undefined);
|
assert.equal(subset.get('military'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cesium-only layers without attachRenderer: rebuilt for each Cesium catalog.
|
test('both production registries attach to the same 37 session models', async (t) => {
|
||||||
const CESIUM_ONLY_LAYERS = [
|
|
||||||
'drone-airspace',
|
|
||||||
'osm-places',
|
|
||||||
'sea-marks',
|
|
||||||
'ski-pistes',
|
|
||||||
];
|
|
||||||
|
|
||||||
test('both production registries attach to the same 33 session models', async (t) => {
|
|
||||||
const { registerNavaraLayerRenderers } = await import('./navaraMode.js');
|
const { registerNavaraLayerRenderers } = await import('./navaraMode.js');
|
||||||
const lifetime = new AbortController();
|
const lifetime = new AbortController();
|
||||||
t.after(() => lifetime.abort());
|
t.after(() => lifetime.abort());
|
||||||
@@ -272,20 +264,13 @@ test('both production registries attach to the same 33 session models', async (t
|
|||||||
const cesium = createApplicationCatalog(options);
|
const cesium = createApplicationCatalog(options);
|
||||||
await registerNavaraLayerRenderers();
|
await registerNavaraLayerRenderers();
|
||||||
const navara = createApplicationCatalog({ ...options, engineKind: 'navara' });
|
const navara = createApplicationCatalog({ ...options, engineKind: 'navara' });
|
||||||
const shared = cesium.layers.filter(
|
assert.equal(cesium.layers.length, 37);
|
||||||
({ id }) => !CESIUM_ONLY_LAYERS.includes(id),
|
|
||||||
);
|
|
||||||
assert.equal(cesium.layers.length, 33 + CESIUM_ONLY_LAYERS.length);
|
|
||||||
assert.equal(shared.length, 33);
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
navara.layers.map(({ id }) => id),
|
navara.layers.map(({ id }) => id),
|
||||||
shared.map(({ id }) => id),
|
cesium.layers.map(({ id }) => id),
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(navara.metadata, cesium.metadata);
|
||||||
navara.unavailableMetadata.map(({ id }) => id).sort(),
|
for (const layer of cesium.layers) {
|
||||||
[...CESIUM_ONLY_LAYERS].sort(),
|
|
||||||
);
|
|
||||||
for (const layer of shared) {
|
|
||||||
assert.equal(navara.get(layer.id), layer, layer.id);
|
assert.equal(navara.get(layer.id), layer, layer.id);
|
||||||
assert.equal(typeof layer.attachRenderer, 'function', layer.id);
|
assert.equal(typeof layer.attachRenderer, 'function', layer.id);
|
||||||
assert.equal(typeof layer.detachRenderer, 'function', layer.id);
|
assert.equal(typeof layer.detachRenderer, 'function', layer.id);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
|
import {
|
||||||
|
createDroneAirspaceLayer,
|
||||||
|
createDroneAirspaceCesiumRenderer,
|
||||||
|
} from '../../layers/droneAirspace/index.js';
|
||||||
|
import { rendererFor } from './renderers.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||||
@@ -15,6 +19,16 @@ export function createApplicationDroneAirspace(options) {
|
|||||||
},
|
},
|
||||||
screenSpaceEventHandlerFactory: (viewer) =>
|
screenSpaceEventHandlerFactory: (viewer) =>
|
||||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||||
|
createRenderer: ({ viewer, engine, screenSpaceEventHandlerFactory }) =>
|
||||||
|
engine
|
||||||
|
? rendererFor('drone-airspace', engine, {
|
||||||
|
viewer,
|
||||||
|
screenSpaceEventHandlerFactory,
|
||||||
|
})
|
||||||
|
: createDroneAirspaceCesiumRenderer({
|
||||||
|
viewer,
|
||||||
|
screenSpaceEventHandlerFactory,
|
||||||
|
}),
|
||||||
picking,
|
picking,
|
||||||
pointer: { isPointerFree },
|
pointer: { isPointerFree },
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { createOsmPlacesLayer } from '../../layers/osmPlaces/index.js';
|
import {
|
||||||
|
createOsmPlacesLayer,
|
||||||
|
createOsmPlacesCesiumRenderer,
|
||||||
|
} from '../../layers/osmPlaces/index.js';
|
||||||
import { overlayHost } from './overlayHost.js';
|
import { overlayHost } from './overlayHost.js';
|
||||||
|
import { rendererFor } from './renderers.js';
|
||||||
|
|
||||||
/** Wire OSM Places labels to the application overlay host. */
|
/** Wire OSM Places labels to the application overlay host. */
|
||||||
export function createApplicationOsmPlaces() {
|
export function createApplicationOsmPlaces() {
|
||||||
return createOsmPlacesLayer({ overlayHost });
|
return createOsmPlacesLayer({
|
||||||
|
overlayHost,
|
||||||
|
createRenderer: ({ viewer, engine }) =>
|
||||||
|
engine
|
||||||
|
? rendererFor('osm-places', engine, { viewer })
|
||||||
|
: createOsmPlacesCesiumRenderer({ viewer }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js';
|
import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js';
|
||||||
|
import { createDroneAirspaceCesiumRenderer } from '../../layers/droneAirspace/index.js';
|
||||||
|
import { createOsmPlacesCesiumRenderer } from '../../layers/osmPlaces/index.js';
|
||||||
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
|
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
|
||||||
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
|
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
|
||||||
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
|
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
|
||||||
@@ -99,6 +101,9 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
|||||||
'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options),
|
'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options),
|
||||||
'fire-perimeters': (_engine, options) =>
|
'fire-perimeters': (_engine, options) =>
|
||||||
createPerimetersCesiumRenderer(options),
|
createPerimetersCesiumRenderer(options),
|
||||||
|
'drone-airspace': (_engine, options) =>
|
||||||
|
createDroneAirspaceCesiumRenderer(options),
|
||||||
|
'osm-places': (_engine, options) => createOsmPlacesCesiumRenderer(options),
|
||||||
'local-firms': createFirmsCesiumRenderer,
|
'local-firms': createFirmsCesiumRenderer,
|
||||||
'rocket-launches': createLaunchesCesiumRenderer,
|
'rocket-launches': createLaunchesCesiumRenderer,
|
||||||
satellites: createSatellitesCesiumRenderer,
|
satellites: createSatellitesCesiumRenderer,
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
|
|||||||
'air-quality',
|
'air-quality',
|
||||||
'gnss-integrity',
|
'gnss-integrity',
|
||||||
'fire-perimeters',
|
'fire-perimeters',
|
||||||
|
'drone-airspace',
|
||||||
|
'osm-places',
|
||||||
'local-firms',
|
'local-firms',
|
||||||
'rocket-launches',
|
'rocket-launches',
|
||||||
'satellites',
|
'satellites',
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ test('the standalone startup graph can be imported before the Cesium SDK exists'
|
|||||||
const { createStandaloneCatalog } = await import('./src/standalone/catalog.js');
|
const { createStandaloneCatalog } = await import('./src/standalone/catalog.js');
|
||||||
const lifetime = new AbortController();
|
const lifetime = new AbortController();
|
||||||
const catalog = createStandaloneCatalog({ engineKind: 'navara', signal: lifetime.signal });
|
const catalog = createStandaloneCatalog({ engineKind: 'navara', signal: lifetime.signal });
|
||||||
if (catalog.layers.length !== 33) throw new Error('Incomplete cold Navara catalog');
|
if (catalog.layers.length !== 37) throw new Error('Incomplete cold Navara catalog');
|
||||||
lifetime.abort();
|
lifetime.abort();
|
||||||
`;
|
`;
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'fire-perimeters',
|
'fire-perimeters',
|
||||||
'gnss-integrity',
|
'gnss-integrity',
|
||||||
's2-ghostmaps',
|
's2-ghostmaps',
|
||||||
|
'drone-airspace',
|
||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
'street-level',
|
'street-level',
|
||||||
'satellites',
|
'satellites',
|
||||||
@@ -64,6 +65,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'bikeshare',
|
'bikeshare',
|
||||||
'directions',
|
'directions',
|
||||||
'recent-imagery',
|
'recent-imagery',
|
||||||
|
'osm-places',
|
||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
'military-installations',
|
'military-installations',
|
||||||
'military-awareness',
|
'military-awareness',
|
||||||
@@ -72,6 +74,8 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'weather-satellite',
|
'weather-satellite',
|
||||||
'weather-lightning',
|
'weather-lightning',
|
||||||
'weather-cyclones',
|
'weather-cyclones',
|
||||||
|
'sea-marks',
|
||||||
|
'ski-pistes',
|
||||||
'local-datacenters',
|
'local-datacenters',
|
||||||
'local-dams',
|
'local-dams',
|
||||||
'telegeography-submarine-cables',
|
'telegeography-submarine-cables',
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ export async function registerNavaraLayerRenderers() {
|
|||||||
...navaraViews.get(engine),
|
...navaraViews.get(engine),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
'osm-places': async () =>
|
||||||
|
(await import('../layers/osmPlaces/render/navara.js'))
|
||||||
|
.createOsmPlacesNavaraRenderer,
|
||||||
|
'drone-airspace': async () => {
|
||||||
|
const { createDroneAirspaceNavaraRenderer } =
|
||||||
|
await import('../layers/droneAirspace/render/navara.js');
|
||||||
|
return (engine) =>
|
||||||
|
createDroneAirspaceNavaraRenderer(engine, navaraViews.get(engine));
|
||||||
|
},
|
||||||
'fire-perimeters': async () => {
|
'fire-perimeters': async () => {
|
||||||
const { createPerimetersNavaraRenderer } =
|
const { createPerimetersNavaraRenderer } =
|
||||||
await import('../layers/perimeters/render/navara.js');
|
await import('../layers/perimeters/render/navara.js');
|
||||||
@@ -295,6 +304,9 @@ export async function registerNavaraLayerRenderers() {
|
|||||||
'ais-live-vessels': async () =>
|
'ais-live-vessels': async () =>
|
||||||
(await import('../layers/vessels/render/navara.js'))
|
(await import('../layers/vessels/render/navara.js'))
|
||||||
.createVesselsNavaraRenderer,
|
.createVesselsNavaraRenderer,
|
||||||
|
// The open map overlays drape themselves through engine.maps.addOverlay.
|
||||||
|
'sea-marks': async () => () => null,
|
||||||
|
'ski-pistes': async () => () => null,
|
||||||
};
|
};
|
||||||
const missing = Object.keys(loaders).filter(
|
const missing = Object.keys(loaders).filter(
|
||||||
(id) => !layerAvailableOn(id, 'navara'),
|
(id) => !layerAvailableOn(id, 'navara'),
|
||||||
|
|||||||
+47
-4
@@ -75,6 +75,10 @@ export function createOsmOverlayLayer(
|
|||||||
if (!OSM_OVERLAYS.includes(overlay))
|
if (!OSM_OVERLAYS.includes(overlay))
|
||||||
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
|
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
|
||||||
let viewer = null;
|
let viewer = null;
|
||||||
|
// A globe engine without a Cesium viewer (Navara) takes the overlay
|
||||||
|
// through engine.maps.addOverlay; its removal while shown.
|
||||||
|
let engine = null;
|
||||||
|
let removeEngineOverlay = null;
|
||||||
let mapStack = null;
|
let mapStack = null;
|
||||||
let unsubscribeMap = null;
|
let unsubscribeMap = null;
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
@@ -105,7 +109,10 @@ export function createOsmOverlayLayer(
|
|||||||
|
|
||||||
/** Create, move or drop the owned imagery layer to match the state. */
|
/** Create, move or drop the owned imagery layer to match the state. */
|
||||||
function sync() {
|
function sync() {
|
||||||
if (!viewer) return;
|
if (!viewer) {
|
||||||
|
syncEngine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
|
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
detach(true);
|
detach(true);
|
||||||
@@ -126,14 +133,47 @@ export function createOsmOverlayLayer(
|
|||||||
viewer.scene?.requestRender?.();
|
viewer.scene?.requestRender?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncEngine() {
|
||||||
|
if (!engine) return;
|
||||||
|
if (enabled && !removeEngineOverlay)
|
||||||
|
removeEngineOverlay =
|
||||||
|
engine.maps?.addOverlay?.({
|
||||||
|
url: overlay.urlTemplate,
|
||||||
|
maxZoom: overlay.maximumLevel,
|
||||||
|
credit: overlay.credit,
|
||||||
|
}) ?? null;
|
||||||
|
else if (!enabled && removeEngineOverlay) {
|
||||||
|
removeEngineOverlay();
|
||||||
|
removeEngineOverlay = null;
|
||||||
|
}
|
||||||
|
hostInfo = removeEngineOverlay
|
||||||
|
? { collection: null, kind: 'globe' }
|
||||||
|
: { collection: null, kind: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: overlay.id,
|
id: overlay.id,
|
||||||
name: overlay.name,
|
name: overlay.name,
|
||||||
icon: overlay.icon,
|
icon: overlay.icon,
|
||||||
source: overlay.source,
|
source: overlay.source,
|
||||||
updateInterval: 0,
|
updateInterval: 0,
|
||||||
init(nextViewer) {
|
init(nextViewer, { engine: nextEngine = null } = {}) {
|
||||||
viewer = nextViewer;
|
this.attachRenderer(nextViewer, { engine: nextEngine });
|
||||||
|
},
|
||||||
|
/** Drape on a globe: the Cesium viewer's imagery, or the engine's. */
|
||||||
|
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
|
||||||
|
viewer = nextViewer ?? null;
|
||||||
|
engine = viewer ? null : nextEngine;
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
/** Leave the globe; the enabled state stays for the next one. */
|
||||||
|
detachRenderer() {
|
||||||
|
detach(true);
|
||||||
|
removeEngineOverlay?.();
|
||||||
|
removeEngineOverlay = null;
|
||||||
|
viewer = null;
|
||||||
|
engine = null;
|
||||||
|
hostInfo = { collection: null, kind: 'none' };
|
||||||
},
|
},
|
||||||
/** Follow map-stack switches so the drape moves with the surface. */
|
/** Follow map-stack switches so the drape moves with the surface. */
|
||||||
attachMapStackController(controller) {
|
attachMapStackController(controller) {
|
||||||
@@ -159,7 +199,7 @@ export function createOsmOverlayLayer(
|
|||||||
getStats() {
|
getStats() {
|
||||||
// Without a controller subscription the stats poll doubles as the
|
// Without a controller subscription the stats poll doubles as the
|
||||||
// map-stack watch.
|
// map-stack watch.
|
||||||
if (enabled && !unsubscribeMap) {
|
if (enabled && viewer && !unsubscribeMap) {
|
||||||
const next = resolveHost();
|
const next = resolveHost();
|
||||||
if (next.collection !== hostInfo.collection) sync();
|
if (next.collection !== hostInfo.collection) sync();
|
||||||
}
|
}
|
||||||
@@ -175,6 +215,9 @@ export function createOsmOverlayLayer(
|
|||||||
destroy() {
|
destroy() {
|
||||||
enabled = false;
|
enabled = false;
|
||||||
detach(true);
|
detach(true);
|
||||||
|
removeEngineOverlay?.();
|
||||||
|
removeEngineOverlay = null;
|
||||||
|
engine = null;
|
||||||
unsubscribeMap?.();
|
unsubscribeMap?.();
|
||||||
unsubscribeMap = null;
|
unsubscribeMap = null;
|
||||||
mapStack = null;
|
mapStack = null;
|
||||||
|
|||||||
@@ -165,3 +165,33 @@ test('destroy removes owned imagery and unsubscribes from the map stack', () =>
|
|||||||
assert.equal(created[0].destroyed, true);
|
assert.equal(created[0].destroyed, true);
|
||||||
assert.equal(mapStack.subscribers.size, 0);
|
assert.equal(mapStack.subscribers.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on Navara an overlay drapes through engine.maps and follows the globe', () => {
|
||||||
|
const added = [];
|
||||||
|
const engineWith = () => ({
|
||||||
|
maps: {
|
||||||
|
addOverlay(options) {
|
||||||
|
const entry = { options, removed: false };
|
||||||
|
added.push(entry);
|
||||||
|
return () => (entry.removed = true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [seaMarks] = createOsmOverlayLayers();
|
||||||
|
seaMarks.init(null, { engine: engineWith() });
|
||||||
|
assert.equal(added.length, 0, 'nothing until enabled');
|
||||||
|
seaMarks.enable();
|
||||||
|
assert.equal(added.length, 1);
|
||||||
|
assert.equal(added[0].options.url, OSM_OVERLAYS[0].urlTemplate);
|
||||||
|
assert.equal(added[0].options.credit, OSM_OVERLAYS[0].credit);
|
||||||
|
assert.equal(seaMarks.getStats().error, null);
|
||||||
|
// An engine switch: the drape leaves with the old globe, comes back on the next.
|
||||||
|
seaMarks.detachRenderer();
|
||||||
|
assert.equal(added[0].removed, true);
|
||||||
|
seaMarks.attachRenderer(null, { engine: engineWith() });
|
||||||
|
assert.equal(added.length, 2);
|
||||||
|
assert.equal(added[1].removed, false);
|
||||||
|
seaMarks.disable();
|
||||||
|
assert.equal(added[1].removed, true);
|
||||||
|
seaMarks.destroy();
|
||||||
|
});
|
||||||
@@ -111,6 +111,15 @@ export function createNavaraMaps(
|
|||||||
}, SETTLE_EVERY_MS);
|
}, SETTLE_EVERY_MS);
|
||||||
};
|
};
|
||||||
const slots = { imagery: null, terrain: null, photoreal: null };
|
const slots = { imagery: null, terrain: null, photoreal: null };
|
||||||
|
// Transparent tile overlays (sea marks, ski pistes) above the base
|
||||||
|
// imagery: Navara draws raster layers in the order they were added.
|
||||||
|
const overlays = new Set();
|
||||||
|
function restackOverlays() {
|
||||||
|
for (const overlay of overlays) {
|
||||||
|
overlay.layer.delete();
|
||||||
|
overlay.layer = view.addLayer({ type: 'raster', source: overlay.source });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Base imagery and photoreal tiles share one switch order (display): the
|
// Base imagery and photoreal tiles share one switch order (display): the
|
||||||
// call made last decides what shows.
|
// call made last decides what shows.
|
||||||
const generations = { display: 0, terrain: 0 };
|
const generations = { display: 0, terrain: 0 };
|
||||||
@@ -136,6 +145,7 @@ export function createNavaraMaps(
|
|||||||
const previous = slots[name];
|
const previous = slots[name];
|
||||||
const source = view.addSource(sourceDesc);
|
const source = view.addSource(sourceDesc);
|
||||||
const layer = view.addLayer({ ...layerDesc, source });
|
const layer = view.addLayer({ ...layerDesc, source });
|
||||||
|
if (name === 'imagery') restackOverlays();
|
||||||
const shown = credits.map((credit) =>
|
const shown = credits.map((credit) =>
|
||||||
typeof credit === 'function' ? credit(layer) : credit,
|
typeof credit === 'function' ? credit(layer) : credit,
|
||||||
);
|
);
|
||||||
@@ -291,6 +301,33 @@ export function createNavaraMaps(
|
|||||||
changed();
|
changed();
|
||||||
},
|
},
|
||||||
photorealActive: () => slots.photoreal != null,
|
photorealActive: () => slots.photoreal != null,
|
||||||
|
/**
|
||||||
|
* Navara-only extension: a transparent XYZ tile overlay ({z}/{x}/{y}
|
||||||
|
* `url`) above the base imagery, with its credit. Returns its removal.
|
||||||
|
*/
|
||||||
|
addOverlay({ url, maxZoom, credit }) {
|
||||||
|
if (destroyed) return () => {};
|
||||||
|
const source = view.addSource({
|
||||||
|
type: 'raster-tile',
|
||||||
|
url,
|
||||||
|
...(maxZoom && { maxZoom }),
|
||||||
|
});
|
||||||
|
const overlay = {
|
||||||
|
source,
|
||||||
|
layer: view.addLayer({ type: 'raster', source }),
|
||||||
|
credits: credit ? [{ attributionHtml: String(credit) }] : [],
|
||||||
|
};
|
||||||
|
overlays.add(overlay);
|
||||||
|
if (overlay.credits.length) view.attribution?.add(overlay.credits);
|
||||||
|
changed();
|
||||||
|
return () => {
|
||||||
|
if (!overlays.delete(overlay)) return;
|
||||||
|
overlay.layer.delete();
|
||||||
|
overlay.source.delete();
|
||||||
|
if (overlay.credits.length) view.attribution?.remove(overlay.credits);
|
||||||
|
changed();
|
||||||
|
};
|
||||||
|
},
|
||||||
current: () => ({ baseImagery: currentBase, photoreal: tilesSource }),
|
current: () => ({ baseImagery: currentBase, photoreal: tilesSource }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -300,6 +337,11 @@ export function createNavaraMaps(
|
|||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
stopSettling();
|
stopSettling();
|
||||||
|
for (const overlay of overlays) {
|
||||||
|
overlay.layer.delete();
|
||||||
|
overlay.source.delete();
|
||||||
|
}
|
||||||
|
overlays.clear();
|
||||||
for (const name of Object.keys(slots)) clear(name);
|
for (const name of Object.keys(slots)) clear(name);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -355,3 +355,40 @@ test('after a map change Navara keeps redrawing while tiles arrive, then stops',
|
|||||||
tick(2000);
|
tick(2000);
|
||||||
assert.equal(view.frames, atDestroy);
|
assert.equal(view.frames, atDestroy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a tile overlay stays above the base imagery across map switches and credits its source', async () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const { maps, destroy } = createNavaraMaps(view, {
|
||||||
|
setInterval: () => 1,
|
||||||
|
clearInterval() {},
|
||||||
|
});
|
||||||
|
await maps.setBaseImagery({ kind: 'osm' });
|
||||||
|
const remove = maps.addOverlay({
|
||||||
|
url: 'https://tiles.example/seamark/{z}/{x}/{y}.png',
|
||||||
|
maxZoom: 18,
|
||||||
|
credit: 'Sea marks © example',
|
||||||
|
});
|
||||||
|
const order = () =>
|
||||||
|
view.layers.map(
|
||||||
|
(layer) =>
|
||||||
|
view.sources.find((source) => source === layer.desc.source)?.desc.url,
|
||||||
|
);
|
||||||
|
assert.deepEqual(order(), [
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
'https://tiles.example/seamark/{z}/{x}/{y}.png',
|
||||||
|
]);
|
||||||
|
assert.ok(
|
||||||
|
view.shown.some((item) => item.attributionHtml === 'Sea marks © example'),
|
||||||
|
);
|
||||||
|
// A new base layer goes in on top; the overlay is put back above it.
|
||||||
|
await maps.setBaseImagery({ kind: 'esri-imagery' });
|
||||||
|
assert.equal(order().at(-1), 'https://tiles.example/seamark/{z}/{x}/{y}.png');
|
||||||
|
assert.equal(order().length, 2);
|
||||||
|
remove();
|
||||||
|
assert.equal(order().length, 1);
|
||||||
|
assert.ok(
|
||||||
|
!view.shown.some((item) => item.attributionHtml === 'Sea marks © example'),
|
||||||
|
);
|
||||||
|
remove();
|
||||||
|
destroy();
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import * as geo from '../../geo/index.js';
|
||||||
import { msg, t } from '../../i18n/index.js';
|
import { msg, t } from '../../i18n/index.js';
|
||||||
|
import { createZonesCesiumRenderer } from '../shared/render/cesiumZones.js';
|
||||||
import {
|
import {
|
||||||
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
||||||
droneAirspaceAnchorDegrees,
|
droneAirspaceAnchorDegrees,
|
||||||
@@ -15,8 +17,19 @@ export function droneAirspaceColor(typeCode) {
|
|||||||
return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode));
|
return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ringPositions = (ring) =>
|
/** The zones on a Cesium globe (the default renderer). */
|
||||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
export function createDroneAirspaceCesiumRenderer({
|
||||||
|
viewer,
|
||||||
|
screenSpaceEventHandlerFactory,
|
||||||
|
}) {
|
||||||
|
return createZonesCesiumRenderer({
|
||||||
|
viewer,
|
||||||
|
screenSpaceEventHandlerFactory,
|
||||||
|
name: 'drone-airspace',
|
||||||
|
idPrefix: 'drone-airspace',
|
||||||
|
colorOf: (row) => droneAirspaceAccent(row.typeCode),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FNV-1a over a zone's rings: a short, deterministic fingerprint so a
|
* FNV-1a over a zone's rings: a short, deterministic fingerprint so a
|
||||||
@@ -46,13 +59,16 @@ export function createDroneAirspaceLayer({
|
|||||||
screenSpaceEventHandlerFactory = null,
|
screenSpaceEventHandlerFactory = null,
|
||||||
picking = null,
|
picking = null,
|
||||||
pointer = null,
|
pointer = null,
|
||||||
|
createRenderer = createDroneAirspaceCesiumRenderer,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof source?.getSnapshot !== 'function')
|
if (typeof source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('Drone airspace requires a snapshot source');
|
throw new TypeError('Drone airspace requires a snapshot source');
|
||||||
let _viewer = null;
|
let initialized = false;
|
||||||
|
let renderer = null;
|
||||||
|
/** The rows drawn, kept for the next globe's renderer. */
|
||||||
|
let _rows = [];
|
||||||
let _request = null;
|
let _request = null;
|
||||||
let _snapshotSignature = null;
|
let _snapshotSignature = null;
|
||||||
let _dataSource = null;
|
|
||||||
let _count = 0;
|
let _count = 0;
|
||||||
let _lastUpdate = null;
|
let _lastUpdate = null;
|
||||||
let _lastError = null;
|
let _lastError = null;
|
||||||
@@ -68,7 +84,7 @@ export function createDroneAirspaceLayer({
|
|||||||
overlayHost && screenSpaceEventHandlerFactory && picking;
|
overlayHost && screenSpaceEventHandlerFactory && picking;
|
||||||
|
|
||||||
function publishSelectedCard() {
|
function publishSelectedCard() {
|
||||||
if (!canSelect()) return;
|
if (!canSelect() || !renderer) return;
|
||||||
const row = _selectedId ? _rowById.get(_selectedId) : null;
|
const row = _selectedId ? _rowById.get(_selectedId) : null;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
_selectedId = null;
|
_selectedId = null;
|
||||||
@@ -82,7 +98,7 @@ export function createDroneAirspaceLayer({
|
|||||||
}
|
}
|
||||||
const card = {
|
const card = {
|
||||||
...buildDroneAirspaceCard(row),
|
...buildDroneAirspaceCard(row),
|
||||||
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
|
position: geo.cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
|
||||||
};
|
};
|
||||||
_selectedCardId = card.id;
|
_selectedCardId = card.id;
|
||||||
overlayHost.setEntries(
|
overlayHost.setEntries(
|
||||||
@@ -102,14 +118,13 @@ export function createDroneAirspaceLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function installClickHandler() {
|
function installClickHandler() {
|
||||||
if (!canSelect() || _clickHandler || !_viewer) return;
|
if (!canSelect() || _clickHandler || !renderer) return;
|
||||||
// Deliberately NOT registered in the pick-ownership registry: sibling
|
// Deliberately NOT registered in the pick-ownership registry: sibling
|
||||||
// layers yield to owned picks before their own overlay-card hit-tests,
|
// layers yield to owned picks before their own overlay-card hit-tests,
|
||||||
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
|
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
|
||||||
// cards inert anywhere over a zone. Unowned, our picks read as
|
// cards inert anywhere over a zone. Unowned, our picks read as
|
||||||
// "empty space" to siblings — the same contract as fire perimeters.
|
// "empty space" to siblings — the same contract as fire perimeters.
|
||||||
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
|
_clickHandler = renderer.onClick((click) => {
|
||||||
_clickHandler.setInputAction((click) => {
|
|
||||||
if (pointer && !pointer.isPointerFree()) return;
|
if (pointer && !pointer.isPointerFree()) return;
|
||||||
const cardHit = overlayHost.hitTest?.(
|
const cardHit = overlayHost.hitTest?.(
|
||||||
click.position?.x,
|
click.position?.x,
|
||||||
@@ -117,7 +132,7 @@ export function createDroneAirspaceLayer({
|
|||||||
{ sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID },
|
{ sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID },
|
||||||
);
|
);
|
||||||
if (cardHit && cardHit.entryId === _selectedCardId) return;
|
if (cardHit && cardHit.entryId === _selectedCardId) return;
|
||||||
const picked = _viewer.scene.pick(click.position);
|
const picked = renderer.pick(click);
|
||||||
const zoneId = picked ? pickedZoneId(picked) : null;
|
const zoneId = picked ? pickedZoneId(picked) : null;
|
||||||
if (zoneId) {
|
if (zoneId) {
|
||||||
_selectedId = zoneId;
|
_selectedId = zoneId;
|
||||||
@@ -132,12 +147,12 @@ export function createDroneAirspaceLayer({
|
|||||||
_selectedId = null;
|
_selectedId = null;
|
||||||
publishSelectedCard();
|
publishSelectedCard();
|
||||||
}
|
}
|
||||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeClickHandler() {
|
function removeClickHandler() {
|
||||||
if (_clickHandler) {
|
if (_clickHandler) {
|
||||||
_clickHandler.destroy();
|
_clickHandler();
|
||||||
_clickHandler = null;
|
_clickHandler = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,24 +173,49 @@ export function createDroneAirspaceLayer({
|
|||||||
source: 'FAA SUA',
|
source: 'FAA SUA',
|
||||||
updateInterval: 3600000,
|
updateInterval: 3600000,
|
||||||
|
|
||||||
init(viewer) {
|
init(viewer, { engine = null } = {}) {
|
||||||
if (_viewer)
|
if (initialized)
|
||||||
throw new Error('Drone airspace layer is already initialized');
|
throw new Error('Drone airspace layer is already initialized');
|
||||||
_viewer = viewer;
|
initialized = true;
|
||||||
_dataSource = new Cesium.CustomDataSource('drone-airspace');
|
try {
|
||||||
_dataSource.show = false;
|
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
||||||
viewer.dataSources.add(_dataSource);
|
} catch (error) {
|
||||||
_count = 0;
|
this.destroy();
|
||||||
_lastUpdate = null;
|
throw error;
|
||||||
_lastError = null;
|
}
|
||||||
_stale = false;
|
|
||||||
_enabled = false;
|
|
||||||
console.log('[Data:DroneAirspace] Initialized');
|
console.log('[Data:DroneAirspace] Initialized');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Draw on a globe: the rows already fetched, and the selection. */
|
||||||
|
attachRenderer(viewer, { engine = null } = {}) {
|
||||||
|
if (!initialized || renderer)
|
||||||
|
throw new Error('Invalid drone airspace scene attachment');
|
||||||
|
renderer = createRenderer({
|
||||||
|
viewer,
|
||||||
|
engine,
|
||||||
|
screenSpaceEventHandlerFactory,
|
||||||
|
});
|
||||||
|
renderer.setVisible(_enabled);
|
||||||
|
if (_rows.length) renderer.replace(_rows);
|
||||||
|
if (_enabled) {
|
||||||
|
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true);
|
||||||
|
installClickHandler();
|
||||||
|
if (_selectedId) publishSelectedCard();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Leave the globe; the rows and selection stay for the next one. */
|
||||||
|
detachRenderer() {
|
||||||
|
removeClickHandler();
|
||||||
|
overlayHost?.clearSource(DRONE_AIRSPACE_OVERLAY_SOURCE_ID);
|
||||||
|
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false);
|
||||||
|
renderer?.destroy();
|
||||||
|
renderer = null;
|
||||||
|
},
|
||||||
|
|
||||||
enable() {
|
enable() {
|
||||||
_enabled = true;
|
_enabled = true;
|
||||||
if (_dataSource) _dataSource.show = true;
|
renderer?.setVisible(true);
|
||||||
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true);
|
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true);
|
||||||
installClickHandler();
|
installClickHandler();
|
||||||
},
|
},
|
||||||
@@ -184,13 +224,13 @@ export function createDroneAirspaceLayer({
|
|||||||
_request?.abort();
|
_request?.abort();
|
||||||
_request = null;
|
_request = null;
|
||||||
_enabled = false;
|
_enabled = false;
|
||||||
if (_dataSource) _dataSource.show = false;
|
renderer?.setVisible(false);
|
||||||
removeClickHandler();
|
removeClickHandler();
|
||||||
clearSelection();
|
clearSelection();
|
||||||
},
|
},
|
||||||
|
|
||||||
async update() {
|
async update() {
|
||||||
if (!_enabled || !_dataSource) return false;
|
if (!_enabled) return false;
|
||||||
_request?.abort();
|
_request?.abort();
|
||||||
const request = new AbortController();
|
const request = new AbortController();
|
||||||
_request = request;
|
_request = request;
|
||||||
@@ -227,55 +267,25 @@ export function createDroneAirspaceLayer({
|
|||||||
_lastError = null;
|
_lastError = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const nextEntities = [];
|
|
||||||
_rowById.clear();
|
_rowById.clear();
|
||||||
|
let polygonCount = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const color = droneAirspaceColor(row.typeCode);
|
polygonCount += row.polygons.length;
|
||||||
for (const [index, rings] of row.polygons.entries()) {
|
|
||||||
const [outer, ...holes] = rings;
|
|
||||||
const outerPositions = ringPositions(outer);
|
|
||||||
nextEntities.push(
|
|
||||||
new Cesium.Entity({
|
|
||||||
id: `drone-airspace:${row.stableId}:${index}`,
|
|
||||||
polygon: {
|
|
||||||
hierarchy: new Cesium.PolygonHierarchy(
|
|
||||||
outerPositions,
|
|
||||||
holes.map(
|
|
||||||
(hole) =>
|
|
||||||
new Cesium.PolygonHierarchy(ringPositions(hole)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
material: new Cesium.ColorMaterialProperty(
|
|
||||||
color.withAlpha(0.25),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
polyline: {
|
|
||||||
positions: outerPositions,
|
|
||||||
clampToGround: true,
|
|
||||||
width: 2,
|
|
||||||
material: new Cesium.ColorMaterialProperty(
|
|
||||||
color.withAlpha(0.9),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { polygons, ...facts } = row;
|
const { polygons, ...facts } = row;
|
||||||
_rowById.set(row.stableId, {
|
_rowById.set(row.stableId, {
|
||||||
...facts,
|
...facts,
|
||||||
anchor: droneAirspaceAnchorDegrees(polygons),
|
anchor: droneAirspaceAnchorDegrees(polygons),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
_rows = rows;
|
||||||
_dataSource.entities.removeAll();
|
renderer?.replace(rows);
|
||||||
for (const entity of nextEntities) _dataSource.entities.add(entity);
|
|
||||||
_snapshotSignature = signature;
|
_snapshotSignature = signature;
|
||||||
if (_selectedId) publishSelectedCard();
|
if (_selectedId) publishSelectedCard();
|
||||||
_count = rows.length;
|
_count = rows.length;
|
||||||
_lastUpdate = updatedAt;
|
_lastUpdate = updatedAt;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
console.log(
|
console.log(
|
||||||
`[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`,
|
`[Data:DroneAirspace] Updated: ${_count} zones, ${polygonCount} polygons`,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -292,19 +302,17 @@ export function createDroneAirspaceLayer({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy(viewer = _viewer) {
|
destroy() {
|
||||||
_request?.abort();
|
_request?.abort();
|
||||||
_request = null;
|
_request = null;
|
||||||
removeClickHandler();
|
removeClickHandler();
|
||||||
clearSelection();
|
clearSelection();
|
||||||
_rowById.clear();
|
_rowById.clear();
|
||||||
|
_rows = [];
|
||||||
_snapshotSignature = null;
|
_snapshotSignature = null;
|
||||||
_viewer = null;
|
initialized = false;
|
||||||
_enabled = false;
|
_enabled = false;
|
||||||
if (_dataSource) {
|
this.detachRenderer();
|
||||||
viewer.dataSources.remove(_dataSource, true);
|
|
||||||
_dataSource = null;
|
|
||||||
}
|
|
||||||
_count = 0;
|
_count = 0;
|
||||||
_lastUpdate = null;
|
_lastUpdate = null;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
@@ -312,7 +320,7 @@ export function createDroneAirspaceLayer({
|
|||||||
},
|
},
|
||||||
|
|
||||||
getAnalystRecords(maxCount = 2000) {
|
getAnalystRecords(maxCount = 2000) {
|
||||||
if (!_dataSource || !_dataSource.show) return [];
|
if (!_enabled) return [];
|
||||||
const limit = Number.isFinite(maxCount)
|
const limit = Number.isFinite(maxCount)
|
||||||
? Math.max(1, Math.floor(maxCount))
|
? Math.max(1, Math.floor(maxCount))
|
||||||
: 2000;
|
: 2000;
|
||||||
|
|||||||
@@ -216,3 +216,71 @@ test('a boundary change alone redraws the zone; identical geometry does not', as
|
|||||||
);
|
);
|
||||||
assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6);
|
assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an engine switch keeps the zones and the selected card for the next globe', async () => {
|
||||||
|
const renderers = [];
|
||||||
|
const layer = createDroneAirspaceLayer({
|
||||||
|
source: { getSnapshot: async () => [row] },
|
||||||
|
overlayHost: {
|
||||||
|
entries: new Map(),
|
||||||
|
setEntries(sourceId, entries) {
|
||||||
|
this.entries.set(sourceId, entries);
|
||||||
|
},
|
||||||
|
setVisible() {},
|
||||||
|
clearSource(sourceId) {
|
||||||
|
this.entries.delete(sourceId);
|
||||||
|
},
|
||||||
|
hitTest: () => null,
|
||||||
|
},
|
||||||
|
screenSpaceEventHandlerFactory: () => null,
|
||||||
|
picking: {
|
||||||
|
resolvePickId: (picked) => picked?.id ?? null,
|
||||||
|
isOwnedByOtherLayer: () => false,
|
||||||
|
},
|
||||||
|
pointer: { isPointerFree: () => true },
|
||||||
|
createRenderer({ engine }) {
|
||||||
|
const renderer = {
|
||||||
|
engine,
|
||||||
|
rows: [],
|
||||||
|
visible: false,
|
||||||
|
click: null,
|
||||||
|
destroyed: false,
|
||||||
|
setVisible(show) {
|
||||||
|
renderer.visible = show;
|
||||||
|
},
|
||||||
|
replace(rows) {
|
||||||
|
renderer.rows = rows;
|
||||||
|
},
|
||||||
|
onClick(callback) {
|
||||||
|
renderer.click = callback;
|
||||||
|
return () => (renderer.click = null);
|
||||||
|
},
|
||||||
|
pick: () => ({ id: 'drone-airspace:310:0' }),
|
||||||
|
destroy() {
|
||||||
|
renderer.destroyed = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderers.push(renderer);
|
||||||
|
return renderer;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
layer.init(null, { engine: 'cesium' });
|
||||||
|
layer.enable();
|
||||||
|
await layer.update();
|
||||||
|
renderers[0].click({ position: { x: 1, y: 1 } });
|
||||||
|
layer.detachRenderer();
|
||||||
|
assert.equal(renderers[0].destroyed, true);
|
||||||
|
layer.attachRenderer(null, { engine: 'navara' });
|
||||||
|
const [, next] = renderers;
|
||||||
|
assert.equal(next.engine, 'navara');
|
||||||
|
assert.equal(next.visible, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
next.rows.map((zone) => zone.stableId),
|
||||||
|
['310'],
|
||||||
|
'the next globe draws the zones without a refetch',
|
||||||
|
);
|
||||||
|
assert.equal(typeof next.click, 'function', 'and takes clicks');
|
||||||
|
assert.equal(layer.getAnalystRecords().length, 1);
|
||||||
|
layer.destroy();
|
||||||
|
assert.equal(next.destroyed, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { droneAirspaceAccent } from '../cards.js';
|
||||||
|
import { createZonesNavaraRenderer } from '../../shared/render/navaraZones.js';
|
||||||
|
|
||||||
|
/** The FAA zones on Navara, as fire perimeters draw. */
|
||||||
|
export function createDroneAirspaceNavaraRenderer(engine, { view }) {
|
||||||
|
return createZonesNavaraRenderer(
|
||||||
|
engine,
|
||||||
|
{ view },
|
||||||
|
{
|
||||||
|
idPrefix: 'drone-airspace',
|
||||||
|
colorOf: (row) => droneAirspaceAccent(row.typeCode),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,51 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import * as geo from '../../geo/index.js';
|
||||||
import { msg } from '../../i18n/marker.js';
|
import { msg } from '../../i18n/marker.js';
|
||||||
import { OSM_PLACES_LAYER_ID } from './model.js';
|
import { OSM_PLACES_LAYER_ID, OSM_PLACE_COLOR } from './model.js';
|
||||||
import { createNominatimStatus } from '../../sources/nominatimStatus.js';
|
import { createNominatimStatus } from '../../sources/nominatimStatus.js';
|
||||||
export * from './model.js';
|
export * from './model.js';
|
||||||
|
|
||||||
const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID;
|
const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID;
|
||||||
/** Named results that get a map label; the rest are points. */
|
/** Named results that get a map label; the rest are points. */
|
||||||
const LABEL_LIMIT = 48;
|
const LABEL_LIMIT = 48;
|
||||||
const COLOR = '#7fe3ff';
|
const COLOR = OSM_PLACE_COLOR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The places' points on a Cesium globe (the default renderer):
|
||||||
|
* `{ setVisible, replace(records), destroy }`.
|
||||||
|
*/
|
||||||
|
export function createOsmPlacesCesiumRenderer({ viewer }) {
|
||||||
|
const dataSource = new Cesium.CustomDataSource(OSM_PLACES_LAYER_ID);
|
||||||
|
dataSource.show = false;
|
||||||
|
viewer.dataSources.add(dataSource);
|
||||||
|
return {
|
||||||
|
setVisible(show) {
|
||||||
|
dataSource.show = show;
|
||||||
|
},
|
||||||
|
replace(records) {
|
||||||
|
dataSource.entities.removeAll();
|
||||||
|
const color = Cesium.Color.fromCssColorString(COLOR);
|
||||||
|
for (const record of records)
|
||||||
|
dataSource.entities.add({
|
||||||
|
id: `osm-place:${record.id}`,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||||
|
point: {
|
||||||
|
pixelSize: 9,
|
||||||
|
color,
|
||||||
|
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||||
|
outlineWidth: 2,
|
||||||
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
|
// On top only up close (as bikeshare stations): beyond that the
|
||||||
|
// depth test keeps far-side points from showing through the globe.
|
||||||
|
disableDepthTestDistance: 2500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
viewer.dataSources.remove(dataSource, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OSM Places: the results of the last voice OpenStreetMap search ("hospitals
|
* OSM Places: the results of the last voice OpenStreetMap search ("hospitals
|
||||||
@@ -22,10 +60,11 @@ export function createOsmPlacesLayer({
|
|||||||
overlayHost,
|
overlayHost,
|
||||||
// The same once-per-page capability probe as Nominatim's, on Overpass.
|
// The same once-per-page capability probe as Nominatim's, on Overpass.
|
||||||
overpassConfigured = createNominatimStatus({ url: '/api/overpass/status' }),
|
overpassConfigured = createNominatimStatus({ url: '/api/overpass/status' }),
|
||||||
|
createRenderer = createOsmPlacesCesiumRenderer,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!overlayHost) throw new TypeError('OSM Places requires an overlay host');
|
if (!overlayHost) throw new TypeError('OSM Places requires an overlay host');
|
||||||
let viewer = null;
|
let initialized = false;
|
||||||
let dataSource = null;
|
let renderer = null;
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
let records = [];
|
let records = [];
|
||||||
let meta = null;
|
let meta = null;
|
||||||
@@ -34,30 +73,14 @@ export function createOsmPlacesLayer({
|
|||||||
let configured = null;
|
let configured = null;
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!dataSource) return;
|
if (!renderer) return;
|
||||||
dataSource.entities.removeAll();
|
renderer.replace(records);
|
||||||
const color = Cesium.Color.fromCssColorString(COLOR);
|
|
||||||
const overlay = [];
|
const overlay = [];
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
|
if (!record.name || overlay.length >= LABEL_LIMIT) continue;
|
||||||
dataSource.entities.add({
|
|
||||||
id: `osm-place:${record.id}`,
|
|
||||||
position,
|
|
||||||
point: {
|
|
||||||
pixelSize: 9,
|
|
||||||
color,
|
|
||||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
|
||||||
outlineWidth: 2,
|
|
||||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
||||||
// On top only up close (as bikeshare stations): beyond that the
|
|
||||||
// depth test keeps far-side points from showing through the globe.
|
|
||||||
disableDepthTestDistance: 2500,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (record.name && overlay.length < LABEL_LIMIT)
|
|
||||||
overlay.push({
|
overlay.push({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
position,
|
position: geo.cartesian3.fromDegrees(record.lon, record.lat),
|
||||||
variant: 'label',
|
variant: 'label',
|
||||||
title: record.name.slice(0, 40),
|
title: record.name.slice(0, 40),
|
||||||
accent: COLOR,
|
accent: COLOR,
|
||||||
@@ -99,22 +122,38 @@ export function createOsmPlacesLayer({
|
|||||||
: { available: true, source: OSM_PLACES_LAYER_ID, reason: null };
|
: { available: true, source: OSM_PLACES_LAYER_ID, reason: null };
|
||||||
},
|
},
|
||||||
|
|
||||||
init(nextViewer) {
|
init(viewer, { engine = null } = {}) {
|
||||||
viewer = nextViewer;
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
Promise.resolve(overpassConfigured?.())
|
Promise.resolve(overpassConfigured?.())
|
||||||
.then((answer) => {
|
.then((answer) => {
|
||||||
if (typeof answer === 'boolean') configured = answer;
|
if (typeof answer === 'boolean') configured = answer;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
dataSource = new Cesium.CustomDataSource(OSM_PLACES_LAYER_ID);
|
|
||||||
dataSource.show = false;
|
|
||||||
viewer.dataSources.add(dataSource);
|
|
||||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||||
|
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Draw on a globe; the results stay for the next one. */
|
||||||
|
attachRenderer(viewer, { engine = null } = {}) {
|
||||||
|
if (!initialized || renderer)
|
||||||
|
throw new Error('Invalid OSM Places scene attachment');
|
||||||
|
renderer = createRenderer({ viewer, engine });
|
||||||
|
renderer.setVisible(enabled);
|
||||||
|
if (enabled) overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
detachRenderer() {
|
||||||
|
overlayHost.clearSource(OVERLAY_SOURCE_ID);
|
||||||
|
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||||
|
renderer?.destroy();
|
||||||
|
renderer = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
enable() {
|
enable() {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
if (dataSource) dataSource.show = true;
|
renderer?.setVisible(true);
|
||||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
|
overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
|
||||||
render();
|
render();
|
||||||
return true;
|
return true;
|
||||||
@@ -122,7 +161,7 @@ export function createOsmPlacesLayer({
|
|||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
enabled = false;
|
enabled = false;
|
||||||
if (dataSource) dataSource.show = false;
|
renderer?.setVisible(false);
|
||||||
overlayHost.clearSource(OVERLAY_SOURCE_ID);
|
overlayHost.clearSource(OVERLAY_SOURCE_ID);
|
||||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||||
return true;
|
return true;
|
||||||
@@ -135,11 +174,8 @@ export function createOsmPlacesLayer({
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
enabled = false;
|
enabled = false;
|
||||||
overlayHost.clearSource(OVERLAY_SOURCE_ID);
|
this.detachRenderer();
|
||||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
initialized = false;
|
||||||
if (dataSource && viewer) viewer.dataSources.remove(dataSource, true);
|
|
||||||
dataSource = null;
|
|
||||||
viewer = null;
|
|
||||||
records = [];
|
records = [];
|
||||||
meta = null;
|
meta = null;
|
||||||
lastUpdate = null;
|
lastUpdate = null;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const OSM_PLACES_LAYER_ID = 'osm-places';
|
export const OSM_PLACES_LAYER_ID = 'osm-places';
|
||||||
|
/** The places' point and label colour. */
|
||||||
|
export const OSM_PLACE_COLOR = '#7fe3ff';
|
||||||
/** A node and a way/relation of the same name this close are one site mapped twice. */
|
/** A node and a way/relation of the same name this close are one site mapped twice. */
|
||||||
const SAME_SITE_M = 75;
|
const SAME_SITE_M = 75;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
import { rgbaFromCss } from '../../shared/color.js';
|
||||||
|
import { OSM_PLACE_COLOR } from '../model.js';
|
||||||
|
|
||||||
|
// Above the sampled surface, so a point does not sink into it.
|
||||||
|
const HEIGHT_OFFSET_M = 2;
|
||||||
|
// The voice search's result cap; a larger set grows the batch.
|
||||||
|
const INITIAL_CAPACITY = 256;
|
||||||
|
// While the surface under a place has not loaded, its height is asked again.
|
||||||
|
const RESAMPLE_MS = 500;
|
||||||
|
const RESAMPLE_TRIES = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The places' points on Navara, as the Cesium renderer draws them: 9 px
|
||||||
|
* discs with a dark outline, on the surface where it has loaded, on top only
|
||||||
|
* within 2.5 km of the camera. `{ setVisible, replace(records), destroy }`.
|
||||||
|
* Heights are read again after each camera move, and shortly after a draw
|
||||||
|
* that found no surface under a place yet.
|
||||||
|
*/
|
||||||
|
export function createOsmPlacesNavaraRenderer(engine) {
|
||||||
|
if (!engine?.movers)
|
||||||
|
throw new TypeError('OSM Places requires a globe engine');
|
||||||
|
const color = rgbaFromCss(OSM_PLACE_COLOR).rgba();
|
||||||
|
let set = null,
|
||||||
|
capacity = 0,
|
||||||
|
visible = false,
|
||||||
|
records = [],
|
||||||
|
destroyed = false,
|
||||||
|
retry = null,
|
||||||
|
tries = 0;
|
||||||
|
function ensure(count) {
|
||||||
|
if (set && count <= capacity) return;
|
||||||
|
set?.destroy();
|
||||||
|
capacity = Math.max(INITIAL_CAPACITY, count);
|
||||||
|
set = engine.movers.createSet({
|
||||||
|
id: 'osm-places',
|
||||||
|
kind: 'point',
|
||||||
|
capacity,
|
||||||
|
order: 30,
|
||||||
|
depthTest: { disableWithinM: 2500 },
|
||||||
|
point: { sizePx: 9, outlinePx: 2, outlineColor: [0, 0, 0, 0.8] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function draw() {
|
||||||
|
if (destroyed) return;
|
||||||
|
clearTimeout(retry);
|
||||||
|
retry = null;
|
||||||
|
const shown = visible ? records : [];
|
||||||
|
ensure(shown.length);
|
||||||
|
let unsampled = false;
|
||||||
|
shown.forEach((record, index) => {
|
||||||
|
const sampled = engine.surface?.supported
|
||||||
|
? engine.surface.sampleHeight(
|
||||||
|
geo.cartographic.fromDegrees(record.lon, record.lat),
|
||||||
|
[set],
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (!Number.isFinite(sampled)) unsampled = true;
|
||||||
|
set.set(index, {
|
||||||
|
position: geo.cartesian3.fromDegrees(
|
||||||
|
record.lon,
|
||||||
|
record.lat,
|
||||||
|
(Number.isFinite(sampled) ? sampled : 0) + HEIGHT_OFFSET_M,
|
||||||
|
),
|
||||||
|
color,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
set.setCount(shown.length);
|
||||||
|
set.commit();
|
||||||
|
engine.render.request('osm-places');
|
||||||
|
if (unsampled && engine.surface?.supported && tries++ < RESAMPLE_TRIES)
|
||||||
|
retry = setTimeout(draw, RESAMPLE_MS);
|
||||||
|
}
|
||||||
|
const removeMoveEnd = engine.camera?.moveEnd?.addEventListener(() => {
|
||||||
|
tries = 0;
|
||||||
|
if (visible && records.length) draw();
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
setVisible(show) {
|
||||||
|
if (visible === Boolean(show)) return;
|
||||||
|
visible = Boolean(show);
|
||||||
|
draw();
|
||||||
|
},
|
||||||
|
replace(next) {
|
||||||
|
records = next.slice();
|
||||||
|
tries = 0;
|
||||||
|
draw();
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
clearTimeout(retry);
|
||||||
|
removeMoveEnd?.();
|
||||||
|
set?.destroy();
|
||||||
|
set = null;
|
||||||
|
records = [];
|
||||||
|
engine.render.request('osm-places-destroy');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||||
|
import { createOsmPlacesLayer } from '../index.js';
|
||||||
|
import { createOsmPlacesNavaraRenderer } from './navara.js';
|
||||||
|
|
||||||
|
const places = [
|
||||||
|
{ id: 'node/1', name: 'Bir Hospital', lat: 27.7, lon: 85.31 },
|
||||||
|
{ id: 'node/2', name: '', lat: 27.71, lon: 85.32 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function layerOn(engine) {
|
||||||
|
const overlay = new Map();
|
||||||
|
const layer = createOsmPlacesLayer({
|
||||||
|
overpassConfigured: async () => true,
|
||||||
|
overlayHost: {
|
||||||
|
setEntries: (id, entries) => overlay.set(id, entries),
|
||||||
|
setVisible() {},
|
||||||
|
clearSource: (id) => overlay.delete(id),
|
||||||
|
},
|
||||||
|
createRenderer: ({ engine: target }) =>
|
||||||
|
createOsmPlacesNavaraRenderer(target),
|
||||||
|
});
|
||||||
|
layer.init(null, { engine });
|
||||||
|
return { layer, overlay };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('OSM places draw their points on Navara and keep them across a globe switch', () => {
|
||||||
|
const engine = createFakeEngine({ kind: 'navara' });
|
||||||
|
const { layer, overlay } = layerOn(engine);
|
||||||
|
layer.setResults(places, {
|
||||||
|
kind: 'hospital',
|
||||||
|
label: 'Hospitals',
|
||||||
|
area: 'Kathmandu',
|
||||||
|
});
|
||||||
|
const set = () => engine.fake.moverSet('osm-places');
|
||||||
|
assert.equal(set().store.count, 0, 'nothing drawn while off');
|
||||||
|
layer.enable();
|
||||||
|
assert.equal(set().store.count, 2, 'one point per place');
|
||||||
|
assert.equal(
|
||||||
|
overlay.get('osm-places').length,
|
||||||
|
1,
|
||||||
|
'only named places get labels',
|
||||||
|
);
|
||||||
|
layer.detachRenderer();
|
||||||
|
assert.equal(set(), undefined, 'the points go with the globe');
|
||||||
|
const next = createFakeEngine({ kind: 'navara' });
|
||||||
|
layer.attachRenderer(null, { engine: next });
|
||||||
|
assert.equal(next.fake.moverSet('osm-places').store.count, 2);
|
||||||
|
assert.equal(layer.getAnalystRecords().length, 2, 'the results stayed');
|
||||||
|
layer.disable();
|
||||||
|
assert.equal(next.fake.moverSet('osm-places').store.count, 0);
|
||||||
|
layer.destroy();
|
||||||
|
assert.equal(next.fake.moverSet('osm-places'), undefined);
|
||||||
|
});
|
||||||
@@ -1,72 +1,20 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { containmentAccent } from '../cards.js';
|
import { containmentAccent } from '../cards.js';
|
||||||
|
import { createZonesCesiumRenderer } from '../../shared/render/cesiumZones.js';
|
||||||
/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */
|
/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */
|
||||||
export function containmentColor(containedPct) {
|
export function containmentColor(containedPct) {
|
||||||
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
|
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ringPositions = (ring) =>
|
|
||||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
|
||||||
|
|
||||||
export function createPerimetersCesiumRenderer({
|
export function createPerimetersCesiumRenderer({
|
||||||
viewer,
|
viewer,
|
||||||
screenSpaceEventHandlerFactory,
|
screenSpaceEventHandlerFactory,
|
||||||
}) {
|
}) {
|
||||||
const dataSource = new Cesium.CustomDataSource('fire-perimeters');
|
return createZonesCesiumRenderer({
|
||||||
dataSource.show = false;
|
viewer,
|
||||||
viewer.dataSources.add(dataSource);
|
screenSpaceEventHandlerFactory,
|
||||||
return {
|
name: 'fire-perimeters',
|
||||||
setVisible(show) {
|
idPrefix: 'fire-perimeter',
|
||||||
dataSource.show = show;
|
colorOf: (row) => containmentAccent(row.containedPct),
|
||||||
},
|
});
|
||||||
pick(click) {
|
|
||||||
return viewer.scene.pick(click.position);
|
|
||||||
},
|
|
||||||
onClick(callback) {
|
|
||||||
const handler = screenSpaceEventHandlerFactory(viewer);
|
|
||||||
handler.setInputAction(callback, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
||||||
return () => handler.destroy();
|
|
||||||
},
|
|
||||||
replace(rows) {
|
|
||||||
const nextEntities = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
const color = containmentColor(row.containedPct);
|
|
||||||
for (const [index, rings] of row.polygons.entries()) {
|
|
||||||
const [outer, ...holes] = rings;
|
|
||||||
const outerPositions = ringPositions(outer);
|
|
||||||
nextEntities.push(
|
|
||||||
new Cesium.Entity({
|
|
||||||
id: `fire-perimeter:${row.stableId}:${index}`,
|
|
||||||
polygon: {
|
|
||||||
hierarchy: new Cesium.PolygonHierarchy(
|
|
||||||
outerPositions,
|
|
||||||
holes.map(
|
|
||||||
(hole) => new Cesium.PolygonHierarchy(ringPositions(hole)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
material: new Cesium.ColorMaterialProperty(
|
|
||||||
color.withAlpha(0.25),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// The fire line: ground-clamped outline (entity polygons cannot
|
|
||||||
// outline clamped geometry themselves).
|
|
||||||
polyline: {
|
|
||||||
positions: outerPositions,
|
|
||||||
clampToGround: true,
|
|
||||||
width: 2,
|
|
||||||
material: new Cesium.ColorMaterialProperty(
|
|
||||||
color.withAlpha(0.9),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.entities.removeAll();
|
|
||||||
for (const entity of nextEntities) dataSource.entities.add(entity);
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
viewer.dataSources.remove(dataSource, true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -1,85 +1,13 @@
|
|||||||
import * as geo from '../../../geo/index.js';
|
|
||||||
import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js';
|
|
||||||
import { containmentAccent } from '../cards.js';
|
import { containmentAccent } from '../cards.js';
|
||||||
|
import { createZonesNavaraRenderer } from '../../shared/render/navaraZones.js';
|
||||||
import { containsGroundPoint } from '../../shared/render/groundRing.js';
|
|
||||||
|
|
||||||
export function createPerimetersNavaraRenderer(engine, { view }) {
|
export function createPerimetersNavaraRenderer(engine, { view }) {
|
||||||
let polygons = [];
|
return createZonesNavaraRenderer(
|
||||||
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
|
engine,
|
||||||
polygon: {
|
{ view },
|
||||||
color,
|
|
||||||
show,
|
|
||||||
clampToGround: true,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.25,
|
|
||||||
},
|
|
||||||
polyline: {
|
|
||||||
color,
|
|
||||||
show,
|
|
||||||
clampToGround: true,
|
|
||||||
width: 2,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.9,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
setVisible: display.setVisible,
|
|
||||||
replace(rows) {
|
|
||||||
const groups = new Map(),
|
|
||||||
next = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
const color = containmentAccent(row.containedPct);
|
|
||||||
if (!groups.has(color)) groups.set(color, []);
|
|
||||||
for (const [index, rings] of row.polygons.entries()) {
|
|
||||||
const id = `fire-perimeter:${row.stableId}:${index}`;
|
|
||||||
const properties = { incident: row.stableId };
|
|
||||||
const group = groups.get(color);
|
|
||||||
group.push(
|
|
||||||
{
|
{
|
||||||
type: 'Feature',
|
idPrefix: 'fire-perimeter',
|
||||||
id,
|
colorOf: (row) => containmentAccent(row.containedPct),
|
||||||
properties,
|
|
||||||
geometry: { type: 'Polygon', coordinates: rings },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Feature',
|
|
||||||
id: id + '-outline',
|
|
||||||
properties,
|
|
||||||
geometry: { type: 'LineString', coordinates: rings[0] },
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
next.push({ id, rings });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
display.replace(groups);
|
|
||||||
polygons = next;
|
|
||||||
},
|
|
||||||
onClick(callback) {
|
|
||||||
// Perimeters never claim a sibling pick. The input port shares the
|
|
||||||
// native click result with other consumers of the same gesture.
|
|
||||||
return engine.input.on('click', callback, { pick: true });
|
|
||||||
},
|
|
||||||
pick(event) {
|
|
||||||
if (event.picked) return event.picked;
|
|
||||||
const point = engine.picker.pickGlobe(event.position);
|
|
||||||
if (!point) return null;
|
|
||||||
const pos = geo.cartographic.fromCartesian(point);
|
|
||||||
const lon = geo.math.toDegrees(pos.longitude),
|
|
||||||
lat = geo.math.toDegrees(pos.latitude);
|
|
||||||
for (let i = polygons.length - 1; i >= 0; i--) {
|
|
||||||
const { id, rings } = polygons[i];
|
|
||||||
if (
|
|
||||||
containsGroundPoint(rings[0], lon, lat) &&
|
|
||||||
!rings.slice(1).some((ring) => containsGroundPoint(ring, lon, lat))
|
|
||||||
)
|
|
||||||
return { id };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
polygons = [];
|
|
||||||
display.destroy();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
const ringPositions = (ring) =>
|
||||||
|
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground zones on a Cesium globe: each row's polygons (GeoJSON-style rings,
|
||||||
|
* outline first) as a translucent fill under a ground-clamped outline, in
|
||||||
|
* the row's CSS colour (`colorOf(row)`). Entity ids are
|
||||||
|
* `${idPrefix}:${row.stableId}:${index}`. Fire perimeters and drone airspace
|
||||||
|
* draw this way.
|
||||||
|
*/
|
||||||
|
export function createZonesCesiumRenderer({
|
||||||
|
viewer,
|
||||||
|
screenSpaceEventHandlerFactory,
|
||||||
|
name,
|
||||||
|
idPrefix,
|
||||||
|
colorOf,
|
||||||
|
}) {
|
||||||
|
const dataSource = new Cesium.CustomDataSource(name);
|
||||||
|
dataSource.show = false;
|
||||||
|
viewer.dataSources.add(dataSource);
|
||||||
|
return {
|
||||||
|
setVisible(show) {
|
||||||
|
dataSource.show = show;
|
||||||
|
},
|
||||||
|
pick(click) {
|
||||||
|
return viewer.scene.pick(click.position);
|
||||||
|
},
|
||||||
|
onClick(callback) {
|
||||||
|
const handler = screenSpaceEventHandlerFactory(viewer);
|
||||||
|
handler.setInputAction(callback, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||||
|
return () => handler.destroy();
|
||||||
|
},
|
||||||
|
replace(rows) {
|
||||||
|
const nextEntities = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const color = Cesium.Color.fromCssColorString(colorOf(row));
|
||||||
|
for (const [index, rings] of row.polygons.entries()) {
|
||||||
|
const [outer, ...holes] = rings;
|
||||||
|
const outerPositions = ringPositions(outer);
|
||||||
|
nextEntities.push(
|
||||||
|
new Cesium.Entity({
|
||||||
|
id: `${idPrefix}:${row.stableId}:${index}`,
|
||||||
|
polygon: {
|
||||||
|
hierarchy: new Cesium.PolygonHierarchy(
|
||||||
|
outerPositions,
|
||||||
|
holes.map(
|
||||||
|
(hole) => new Cesium.PolygonHierarchy(ringPositions(hole)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
material: new Cesium.ColorMaterialProperty(
|
||||||
|
color.withAlpha(0.25),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Ground-clamped outline (entity polygons cannot outline
|
||||||
|
// clamped geometry themselves).
|
||||||
|
polyline: {
|
||||||
|
positions: outerPositions,
|
||||||
|
clampToGround: true,
|
||||||
|
width: 2,
|
||||||
|
material: new Cesium.ColorMaterialProperty(
|
||||||
|
color.withAlpha(0.9),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.entities.removeAll();
|
||||||
|
for (const entity of nextEntities) dataSource.entities.add(entity);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
viewer.dataSources.remove(dataSource, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
import { createNavaraPolygonDisplay } from './navaraPolygons.js';
|
||||||
|
import { containsGroundPoint } from './groundRing.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground zones on Navara, as cesiumZones.js draws them on Cesium: each row's
|
||||||
|
* polygons as a translucent fill under a ground-clamped outline in the row's
|
||||||
|
* CSS colour (`colorOf(row)`), picked by a ground point inside the outline
|
||||||
|
* and outside its holes (`{ id: '${idPrefix}:${stableId}:${index}' }`).
|
||||||
|
*/
|
||||||
|
export function createZonesNavaraRenderer(
|
||||||
|
engine,
|
||||||
|
{ view },
|
||||||
|
{ idPrefix, colorOf },
|
||||||
|
) {
|
||||||
|
let polygons = [];
|
||||||
|
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
|
||||||
|
polygon: {
|
||||||
|
color,
|
||||||
|
show,
|
||||||
|
clampToGround: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.25,
|
||||||
|
},
|
||||||
|
polyline: {
|
||||||
|
color,
|
||||||
|
show,
|
||||||
|
clampToGround: true,
|
||||||
|
width: 2,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
setVisible: display.setVisible,
|
||||||
|
replace(rows) {
|
||||||
|
const groups = new Map(),
|
||||||
|
next = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const color = colorOf(row);
|
||||||
|
if (!groups.has(color)) groups.set(color, []);
|
||||||
|
for (const [index, rings] of row.polygons.entries()) {
|
||||||
|
const id = `${idPrefix}:${row.stableId}:${index}`;
|
||||||
|
const properties = { zone: row.stableId };
|
||||||
|
groups.get(color).push(
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
id,
|
||||||
|
properties,
|
||||||
|
geometry: { type: 'Polygon', coordinates: rings },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
id: id + '-outline',
|
||||||
|
properties,
|
||||||
|
geometry: { type: 'LineString', coordinates: rings[0] },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
next.push({ id, rings });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
display.replace(groups);
|
||||||
|
polygons = next;
|
||||||
|
},
|
||||||
|
onClick(callback) {
|
||||||
|
// Zones never claim a sibling pick. The input port shares the native
|
||||||
|
// click result with other consumers of the same gesture.
|
||||||
|
return engine.input.on('click', callback, { pick: true });
|
||||||
|
},
|
||||||
|
pick(event) {
|
||||||
|
if (event.picked) return event.picked;
|
||||||
|
const point = engine.picker.pickGlobe(event.position);
|
||||||
|
if (!point) return null;
|
||||||
|
const pos = geo.cartographic.fromCartesian(point);
|
||||||
|
const lon = geo.math.toDegrees(pos.longitude),
|
||||||
|
lat = geo.math.toDegrees(pos.latitude);
|
||||||
|
for (let i = polygons.length - 1; i >= 0; i--) {
|
||||||
|
const { id, rings } = polygons[i];
|
||||||
|
if (
|
||||||
|
containsGroundPoint(rings[0], lon, lat) &&
|
||||||
|
!rings.slice(1).some((ring) => containsGroundPoint(ring, lon, lat))
|
||||||
|
)
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
polygons = [];
|
||||||
|
display.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user