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
+818 -275

No files matched your search

+25
View File
@@ -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",
+10 -5
View File
@@ -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'],
() => () =>
+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); 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);
+15 -1
View File
@@ -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,
+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 { 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 }),
});
} }
+5
View File
@@ -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,
+2
View File
@@ -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',
+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 { 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(
+4
View File
@@ -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',
+12
View File
@@ -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
View File
@@ -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;
+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(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();
});
+42
View File
@@ -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);
}, },
}; };
+37
View File
@@ -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();
});
+76 -68
View File
@@ -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);
});
+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),
},
);
}
+72 -36
View File
@@ -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;
+2
View File
@@ -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;
+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 * 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);
},
};
} }
+6 -78
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 { 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();
},
};
} }
+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();
},
};
}