Show drone airspace, OSM places, sea marks and ski pistes on Navara, and keep them across engine switches

This commit is contained in:
German Khokhlov committed 2026-11-05 00:47:08 +04:00
1 parent 43b4e9348b
commit 843c4a111d
25 files changed
+838 -295

No files matched your search

+25
View File
@@ -3357,6 +3357,7 @@
"src/app/layers/perimeters.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/shared/render/cesiumZones.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
@@ -3817,6 +3818,9 @@
"src/layers/gnss/render/navara.js",
"src/layers/perimeters/render/navara.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/navaraGrid.js",
"src/layers/installations/render/navara.js",
@@ -4314,6 +4318,7 @@
"src/app/layers/perimeters.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/shared/render/cesiumZones.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
@@ -5506,6 +5511,7 @@
"src/geo/math.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/shared/render/cesiumZones.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/perimeters/source.js",
@@ -5656,6 +5662,25 @@
"exports": ["./layers/drone-airspace"],
"modules": [
"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/layers/droneAirspace/source.js",
"src/layers/droneAirspace/records.js",
+13 -8
View File
@@ -254,15 +254,14 @@ export function createApplicationCatalog({
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',
() =>
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
sessionModel('drone-airspace', () =>
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
),
],
[
'alpr-cameras',
@@ -352,7 +351,10 @@ export function createApplicationCatalog({
}),
),
],
['osm-places', () => createApplicationOsmPlaces()],
[
'osm-places',
() => sessionModel('osm-places', () => createApplicationOsmPlaces()),
],
['ais-live-vessels', vessels],
['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'],
() =>
+5 -20
View File
@@ -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);
});
// Cesium-only layers without attachRenderer: rebuilt for each Cesium catalog.
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) => {
test('both production registries attach to the same 37 session models', async (t) => {
const { registerNavaraLayerRenderers } = await import('./navaraMode.js');
const lifetime = new AbortController();
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);
await registerNavaraLayerRenderers();
const navara = createApplicationCatalog({ ...options, engineKind: 'navara' });
const shared = cesium.layers.filter(
({ id }) => !CESIUM_ONLY_LAYERS.includes(id),
);
assert.equal(cesium.layers.length, 33 + CESIUM_ONLY_LAYERS.length);
assert.equal(shared.length, 33);
assert.equal(cesium.layers.length, 37);
assert.deepEqual(
navara.layers.map(({ id }) => id),
shared.map(({ id }) => id),
cesium.layers.map(({ id }) => id),
);
assert.deepEqual(
navara.unavailableMetadata.map(({ id }) => id).sort(),
[...CESIUM_ONLY_LAYERS].sort(),
);
for (const layer of shared) {
assert.deepEqual(navara.metadata, cesium.metadata);
for (const layer of cesium.layers) {
assert.equal(navara.get(layer.id), layer, layer.id);
assert.equal(typeof layer.attachRenderer, 'function', layer.id);
assert.equal(typeof layer.detachRenderer, 'function', layer.id);
+15 -1
View File
@@ -1,5 +1,9 @@
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 overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
@@ -15,6 +19,16 @@ export function createApplicationDroneAirspace(options) {
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
createRenderer: ({ viewer, engine, screenSpaceEventHandlerFactory }) =>
engine
? rendererFor('drone-airspace', engine, {
viewer,
screenSpaceEventHandlerFactory,
})
: createDroneAirspaceCesiumRenderer({
viewer,
screenSpaceEventHandlerFactory,
}),
picking,
pointer: { isPointerFree },
...options,
+12 -2
View File
@@ -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 { rendererFor } from './renderers.js';
/** Wire OSM Places labels to the application overlay host. */
export function createApplicationOsmPlaces() {
return createOsmPlacesLayer({ overlayHost });
return createOsmPlacesLayer({
overlayHost,
createRenderer: ({ viewer, engine }) =>
engine
? rendererFor('osm-places', engine, { viewer })
: createOsmPlacesCesiumRenderer({ viewer }),
});
}
+5
View File
@@ -1,4 +1,6 @@
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 { createAirQualityCesiumRenderer } from '../../layers/airQuality/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),
'fire-perimeters': (_engine, options) =>
createPerimetersCesiumRenderer(options),
'drone-airspace': (_engine, options) =>
createDroneAirspaceCesiumRenderer(options),
'osm-places': (_engine, options) => createOsmPlacesCesiumRenderer(options),
'local-firms': createFirmsCesiumRenderer,
'rocket-launches': createLaunchesCesiumRenderer,
satellites: createSatellitesCesiumRenderer,
+2
View File
@@ -62,6 +62,8 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
'air-quality',
'gnss-integrity',
'fire-perimeters',
'drone-airspace',
'osm-places',
'local-firms',
'rocket-launches',
'satellites',
+1 -1
View File
@@ -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 lifetime = new AbortController();
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();
`;
const result = spawnSync(
+4
View File
@@ -53,6 +53,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'fire-perimeters',
'gnss-integrity',
's2-ghostmaps',
'drone-airspace',
'alpr-cameras',
'street-level',
'satellites',
@@ -64,6 +65,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'bikeshare',
'directions',
'recent-imagery',
'osm-places',
'ais-live-vessels',
'military-installations',
'military-awareness',
@@ -72,6 +74,8 @@ test('the Navara mode registers migrated renderers once', async () => {
'weather-satellite',
'weather-lightning',
'weather-cyclones',
'sea-marks',
'ski-pistes',
'local-datacenters',
'local-dams',
'telegeography-submarine-cables',
+12
View File
@@ -244,6 +244,15 @@ export async function registerNavaraLayerRenderers() {
...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 () => {
const { createPerimetersNavaraRenderer } =
await import('../layers/perimeters/render/navara.js');
@@ -295,6 +304,9 @@ export async function registerNavaraLayerRenderers() {
'ais-live-vessels': async () =>
(await import('../layers/vessels/render/navara.js'))
.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(
(id) => !layerAvailableOn(id, 'navara'),
+47 -4
View File
@@ -75,6 +75,10 @@ export function createOsmOverlayLayer(
if (!OSM_OVERLAYS.includes(overlay))
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
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 unsubscribeMap = null;
let enabled = false;
@@ -105,7 +109,10 @@ export function createOsmOverlayLayer(
/** Create, move or drop the owned imagery layer to match the state. */
function sync() {
if (!viewer) return;
if (!viewer) {
syncEngine();
return;
}
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
if (!enabled) {
detach(true);
@@ -126,14 +133,47 @@ export function createOsmOverlayLayer(
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 {
id: overlay.id,
name: overlay.name,
icon: overlay.icon,
source: overlay.source,
updateInterval: 0,
init(nextViewer) {
viewer = nextViewer;
init(nextViewer, { engine: nextEngine = null } = {}) {
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. */
attachMapStackController(controller) {
@@ -159,7 +199,7 @@ export function createOsmOverlayLayer(
getStats() {
// Without a controller subscription the stats poll doubles as the
// map-stack watch.
if (enabled && !unsubscribeMap) {
if (enabled && viewer && !unsubscribeMap) {
const next = resolveHost();
if (next.collection !== hostInfo.collection) sync();
}
@@ -175,6 +215,9 @@ export function createOsmOverlayLayer(
destroy() {
enabled = false;
detach(true);
removeEngineOverlay?.();
removeEngineOverlay = null;
engine = null;
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = null;
+30
View File
@@ -165,3 +165,33 @@ test('destroy removes owned imagery and unsubscribes from the map stack', () =>
assert.equal(created[0].destroyed, true);
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();
});
+42
View File
@@ -111,6 +111,15 @@ export function createNavaraMaps(
}, SETTLE_EVERY_MS);
};
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
// call made last decides what shows.
const generations = { display: 0, terrain: 0 };
@@ -136,6 +145,7 @@ export function createNavaraMaps(
const previous = slots[name];
const source = view.addSource(sourceDesc);
const layer = view.addLayer({ ...layerDesc, source });
if (name === 'imagery') restackOverlays();
const shown = credits.map((credit) =>
typeof credit === 'function' ? credit(layer) : credit,
);
@@ -291,6 +301,33 @@ export function createNavaraMaps(
changed();
},
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 }),
};
@@ -300,6 +337,11 @@ export function createNavaraMaps(
if (destroyed) return;
destroyed = true;
stopSettling();
for (const overlay of overlays) {
overlay.layer.delete();
overlay.source.delete();
}
overlays.clear();
for (const name of Object.keys(slots)) clear(name);
},
};
+37
View File
@@ -355,3 +355,40 @@ test('after a map change Navara keeps redrawing while tiles arrive, then stops',
tick(2000);
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();
});
+76 -68
View File
@@ -1,5 +1,7 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { msg, t } from '../../i18n/index.js';
import { createZonesCesiumRenderer } from '../shared/render/cesiumZones.js';
import {
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
droneAirspaceAnchorDegrees,
@@ -15,8 +17,19 @@ export function droneAirspaceColor(typeCode) {
return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode));
}
const ringPositions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
/** The zones on a Cesium globe (the default renderer). */
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
@@ -46,13 +59,16 @@ export function createDroneAirspaceLayer({
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
createRenderer = createDroneAirspaceCesiumRenderer,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
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 _snapshotSignature = null;
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
@@ -68,7 +84,7 @@ export function createDroneAirspaceLayer({
overlayHost && screenSpaceEventHandlerFactory && picking;
function publishSelectedCard() {
if (!canSelect()) return;
if (!canSelect() || !renderer) return;
const row = _selectedId ? _rowById.get(_selectedId) : null;
if (!row) {
_selectedId = null;
@@ -82,7 +98,7 @@ export function createDroneAirspaceLayer({
}
const card = {
...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;
overlayHost.setEntries(
@@ -102,14 +118,13 @@ export function createDroneAirspaceLayer({
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
if (!canSelect() || _clickHandler || !renderer) return;
// Deliberately NOT registered in the pick-ownership registry: sibling
// layers yield to owned picks before their own overlay-card hit-tests,
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
// cards inert anywhere over a zone. Unowned, our picks read as
// "empty space" to siblings — the same contract as fire perimeters.
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
_clickHandler = renderer.onClick((click) => {
if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.(
click.position?.x,
@@ -117,7 +132,7 @@ export function createDroneAirspaceLayer({
{ sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) return;
const picked = _viewer.scene.pick(click.position);
const picked = renderer.pick(click);
const zoneId = picked ? pickedZoneId(picked) : null;
if (zoneId) {
_selectedId = zoneId;
@@ -132,12 +147,12 @@ export function createDroneAirspaceLayer({
_selectedId = null;
publishSelectedCard();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
});
}
function removeClickHandler() {
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler();
_clickHandler = null;
}
}
@@ -158,24 +173,49 @@ export function createDroneAirspaceLayer({
source: 'FAA SUA',
updateInterval: 3600000,
init(viewer) {
if (_viewer)
init(viewer, { engine = null } = {}) {
if (initialized)
throw new Error('Drone airspace layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('drone-airspace');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_count = 0;
_lastUpdate = null;
_lastError = null;
_stale = false;
_enabled = false;
initialized = true;
try {
if (viewer || engine) this.attachRenderer(viewer, { engine });
} catch (error) {
this.destroy();
throw error;
}
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() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
renderer?.setVisible(true);
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true);
installClickHandler();
},
@@ -184,13 +224,13 @@ export function createDroneAirspaceLayer({
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
renderer?.setVisible(false);
removeClickHandler();
clearSelection();
},
async update() {
if (!_enabled || !_dataSource) return false;
if (!_enabled) return false;
_request?.abort();
const request = new AbortController();
_request = request;
@@ -227,55 +267,25 @@ export function createDroneAirspaceLayer({
_lastError = null;
return true;
}
const nextEntities = [];
_rowById.clear();
let polygonCount = 0;
for (const row of rows) {
const color = droneAirspaceColor(row.typeCode);
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),
),
},
}),
);
}
polygonCount += row.polygons.length;
const { polygons, ...facts } = row;
_rowById.set(row.stableId, {
...facts,
anchor: droneAirspaceAnchorDegrees(polygons),
});
}
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
_rows = rows;
renderer?.replace(rows);
_snapshotSignature = signature;
if (_selectedId) publishSelectedCard();
_count = rows.length;
_lastUpdate = updatedAt;
_lastError = null;
console.log(
`[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`,
`[Data:DroneAirspace] Updated: ${_count} zones, ${polygonCount} polygons`,
);
return true;
} catch (e) {
@@ -292,19 +302,17 @@ export function createDroneAirspaceLayer({
}
},
destroy(viewer = _viewer) {
destroy() {
_request?.abort();
_request = null;
removeClickHandler();
clearSelection();
_rowById.clear();
_rows = [];
_snapshotSignature = null;
_viewer = null;
initialized = false;
_enabled = false;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
this.detachRenderer();
_count = 0;
_lastUpdate = null;
_lastError = null;
@@ -312,7 +320,7 @@ export function createDroneAirspaceLayer({
},
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
if (!_enabled) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 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);
});
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);
});
+14
View File
@@ -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),
},
);
}
+87 -51
View File
@@ -1,13 +1,51 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.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';
export * from './model.js';
const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID;
/** Named results that get a map label; the rest are points. */
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
@@ -22,10 +60,11 @@ export function createOsmPlacesLayer({
overlayHost,
// The same once-per-page capability probe as Nominatim's, on Overpass.
overpassConfigured = createNominatimStatus({ url: '/api/overpass/status' }),
createRenderer = createOsmPlacesCesiumRenderer,
} = {}) {
if (!overlayHost) throw new TypeError('OSM Places requires an overlay host');
let viewer = null;
let dataSource = null;
let initialized = false;
let renderer = null;
let enabled = false;
let records = [];
let meta = null;
@@ -34,44 +73,28 @@ export function createOsmPlacesLayer({
let configured = null;
function render() {
if (!dataSource) return;
dataSource.entities.removeAll();
const color = Cesium.Color.fromCssColorString(COLOR);
if (!renderer) return;
renderer.replace(records);
const overlay = [];
for (const record of records) {
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
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) continue;
overlay.push({
id: record.id,
position: geo.cartesian3.fromDegrees(record.lon, record.lat),
variant: 'label',
title: record.name.slice(0, 40),
accent: COLOR,
priority: LABEL_LIMIT - overlay.length,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 12,
verticalOnly: true,
placement: 'above',
});
if (record.name && overlay.length < LABEL_LIMIT)
overlay.push({
id: record.id,
position,
variant: 'label',
title: record.name.slice(0, 40),
accent: COLOR,
priority: LABEL_LIMIT - overlay.length,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 12,
verticalOnly: true,
placement: 'above',
});
}
if (enabled)
overlayHost.setEntries(OVERLAY_SOURCE_ID, overlay, {
@@ -99,22 +122,38 @@ export function createOsmPlacesLayer({
: { available: true, source: OSM_PLACES_LAYER_ID, reason: null };
},
init(nextViewer) {
viewer = nextViewer;
init(viewer, { engine = null } = {}) {
if (initialized) return;
initialized = true;
Promise.resolve(overpassConfigured?.())
.then((answer) => {
if (typeof answer === 'boolean') configured = answer;
})
.catch(() => {});
dataSource = new Cesium.CustomDataSource(OSM_PLACES_LAYER_ID);
dataSource.show = false;
viewer.dataSources.add(dataSource);
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() {
enabled = true;
if (dataSource) dataSource.show = true;
renderer?.setVisible(true);
overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
render();
return true;
@@ -122,7 +161,7 @@ export function createOsmPlacesLayer({
disable() {
enabled = false;
if (dataSource) dataSource.show = false;
renderer?.setVisible(false);
overlayHost.clearSource(OVERLAY_SOURCE_ID);
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
return true;
@@ -135,11 +174,8 @@ export function createOsmPlacesLayer({
destroy() {
enabled = false;
overlayHost.clearSource(OVERLAY_SOURCE_ID);
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
if (dataSource && viewer) viewer.dataSources.remove(dataSource, true);
dataSource = null;
viewer = null;
this.detachRenderer();
initialized = false;
records = [];
meta = null;
lastUpdate = null;
+2
View File
@@ -5,6 +5,8 @@
*/
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. */
const SAME_SITE_M = 75;
+101
View File
@@ -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);
});
+8 -60
View File
@@ -1,72 +1,20 @@
import * as Cesium from 'cesium';
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). */
export function containmentColor(containedPct) {
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
}
const ringPositions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
export function createPerimetersCesiumRenderer({
viewer,
screenSpaceEventHandlerFactory,
}) {
const dataSource = new Cesium.CustomDataSource('fire-perimeters');
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 = 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);
},
};
return createZonesCesiumRenderer({
viewer,
screenSpaceEventHandlerFactory,
name: 'fire-perimeters',
idPrefix: 'fire-perimeter',
colorOf: (row) => containmentAccent(row.containedPct),
});
}
+8 -80
View File
@@ -1,85 +1,13 @@
import * as geo from '../../../geo/index.js';
import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js';
import { containmentAccent } from '../cards.js';
import { containsGroundPoint } from '../../shared/render/groundRing.js';
import { createZonesNavaraRenderer } from '../../shared/render/navaraZones.js';
export function createPerimetersNavaraRenderer(engine, { view }) {
let polygons = [];
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
polygon: {
color,
show,
clampToGround: true,
transparent: true,
opacity: 0.25,
return createZonesNavaraRenderer(
engine,
{ view },
{
idPrefix: 'fire-perimeter',
colorOf: (row) => containmentAccent(row.containedPct),
},
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',
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) {
// 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();
},
};
);
}
+77
View File
@@ -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);
},
};
}
+92
View File
@@ -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();
},
};
}