mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Show drone airspace, OSM places, sea marks and ski pistes on Navara, and keep them across engine switches
This commit is contained in:
1 parent
43b4e9348b
commit
843c4a111d
25 files changed
+838
-295
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
() =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { droneAirspaceAccent } from '../cards.js';
|
||||
import { createZonesNavaraRenderer } from '../../shared/render/navaraZones.js';
|
||||
|
||||
/** The FAA zones on Navara, as fire perimeters draw. */
|
||||
export function createDroneAirspaceNavaraRenderer(engine, { view }) {
|
||||
return createZonesNavaraRenderer(
|
||||
engine,
|
||||
{ view },
|
||||
{
|
||||
idPrefix: 'drone-airspace',
|
||||
colorOf: (row) => droneAirspaceAccent(row.typeCode),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,51 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { OSM_PLACE_COLOR } from '../model.js';
|
||||
|
||||
// Above the sampled surface, so a point does not sink into it.
|
||||
const HEIGHT_OFFSET_M = 2;
|
||||
// The voice search's result cap; a larger set grows the batch.
|
||||
const INITIAL_CAPACITY = 256;
|
||||
// While the surface under a place has not loaded, its height is asked again.
|
||||
const RESAMPLE_MS = 500;
|
||||
const RESAMPLE_TRIES = 20;
|
||||
|
||||
/**
|
||||
* The places' points on Navara, as the Cesium renderer draws them: 9 px
|
||||
* discs with a dark outline, on the surface where it has loaded, on top only
|
||||
* within 2.5 km of the camera. `{ setVisible, replace(records), destroy }`.
|
||||
* Heights are read again after each camera move, and shortly after a draw
|
||||
* that found no surface under a place yet.
|
||||
*/
|
||||
export function createOsmPlacesNavaraRenderer(engine) {
|
||||
if (!engine?.movers)
|
||||
throw new TypeError('OSM Places requires a globe engine');
|
||||
const color = rgbaFromCss(OSM_PLACE_COLOR).rgba();
|
||||
let set = null,
|
||||
capacity = 0,
|
||||
visible = false,
|
||||
records = [],
|
||||
destroyed = false,
|
||||
retry = null,
|
||||
tries = 0;
|
||||
function ensure(count) {
|
||||
if (set && count <= capacity) return;
|
||||
set?.destroy();
|
||||
capacity = Math.max(INITIAL_CAPACITY, count);
|
||||
set = engine.movers.createSet({
|
||||
id: 'osm-places',
|
||||
kind: 'point',
|
||||
capacity,
|
||||
order: 30,
|
||||
depthTest: { disableWithinM: 2500 },
|
||||
point: { sizePx: 9, outlinePx: 2, outlineColor: [0, 0, 0, 0.8] },
|
||||
});
|
||||
}
|
||||
function draw() {
|
||||
if (destroyed) return;
|
||||
clearTimeout(retry);
|
||||
retry = null;
|
||||
const shown = visible ? records : [];
|
||||
ensure(shown.length);
|
||||
let unsampled = false;
|
||||
shown.forEach((record, index) => {
|
||||
const sampled = engine.surface?.supported
|
||||
? engine.surface.sampleHeight(
|
||||
geo.cartographic.fromDegrees(record.lon, record.lat),
|
||||
[set],
|
||||
)
|
||||
: undefined;
|
||||
if (!Number.isFinite(sampled)) unsampled = true;
|
||||
set.set(index, {
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
(Number.isFinite(sampled) ? sampled : 0) + HEIGHT_OFFSET_M,
|
||||
),
|
||||
color,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
set.setCount(shown.length);
|
||||
set.commit();
|
||||
engine.render.request('osm-places');
|
||||
if (unsampled && engine.surface?.supported && tries++ < RESAMPLE_TRIES)
|
||||
retry = setTimeout(draw, RESAMPLE_MS);
|
||||
}
|
||||
const removeMoveEnd = engine.camera?.moveEnd?.addEventListener(() => {
|
||||
tries = 0;
|
||||
if (visible && records.length) draw();
|
||||
});
|
||||
return {
|
||||
setVisible(show) {
|
||||
if (visible === Boolean(show)) return;
|
||||
visible = Boolean(show);
|
||||
draw();
|
||||
},
|
||||
replace(next) {
|
||||
records = next.slice();
|
||||
tries = 0;
|
||||
draw();
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
clearTimeout(retry);
|
||||
removeMoveEnd?.();
|
||||
set?.destroy();
|
||||
set = null;
|
||||
records = [];
|
||||
engine.render.request('osm-places-destroy');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createOsmPlacesLayer } from '../index.js';
|
||||
import { createOsmPlacesNavaraRenderer } from './navara.js';
|
||||
|
||||
const places = [
|
||||
{ id: 'node/1', name: 'Bir Hospital', lat: 27.7, lon: 85.31 },
|
||||
{ id: 'node/2', name: '', lat: 27.71, lon: 85.32 },
|
||||
];
|
||||
|
||||
function layerOn(engine) {
|
||||
const overlay = new Map();
|
||||
const layer = createOsmPlacesLayer({
|
||||
overpassConfigured: async () => true,
|
||||
overlayHost: {
|
||||
setEntries: (id, entries) => overlay.set(id, entries),
|
||||
setVisible() {},
|
||||
clearSource: (id) => overlay.delete(id),
|
||||
},
|
||||
createRenderer: ({ engine: target }) =>
|
||||
createOsmPlacesNavaraRenderer(target),
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
return { layer, overlay };
|
||||
}
|
||||
|
||||
test('OSM places draw their points on Navara and keep them across a globe switch', () => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
const { layer, overlay } = layerOn(engine);
|
||||
layer.setResults(places, {
|
||||
kind: 'hospital',
|
||||
label: 'Hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
const set = () => engine.fake.moverSet('osm-places');
|
||||
assert.equal(set().store.count, 0, 'nothing drawn while off');
|
||||
layer.enable();
|
||||
assert.equal(set().store.count, 2, 'one point per place');
|
||||
assert.equal(
|
||||
overlay.get('osm-places').length,
|
||||
1,
|
||||
'only named places get labels',
|
||||
);
|
||||
layer.detachRenderer();
|
||||
assert.equal(set(), undefined, 'the points go with the globe');
|
||||
const next = createFakeEngine({ kind: 'navara' });
|
||||
layer.attachRenderer(null, { engine: next });
|
||||
assert.equal(next.fake.moverSet('osm-places').store.count, 2);
|
||||
assert.equal(layer.getAnalystRecords().length, 2, 'the results stayed');
|
||||
layer.disable();
|
||||
assert.equal(next.fake.moverSet('osm-places').store.count, 0);
|
||||
layer.destroy();
|
||||
assert.equal(next.fake.moverSet('osm-places'), undefined);
|
||||
});
|
||||
@@ -1,72 +1,20 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { 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),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const ringPositions = (ring) =>
|
||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||
|
||||
/**
|
||||
* Ground zones on a Cesium globe: each row's polygons (GeoJSON-style rings,
|
||||
* outline first) as a translucent fill under a ground-clamped outline, in
|
||||
* the row's CSS colour (`colorOf(row)`). Entity ids are
|
||||
* `${idPrefix}:${row.stableId}:${index}`. Fire perimeters and drone airspace
|
||||
* draw this way.
|
||||
*/
|
||||
export function createZonesCesiumRenderer({
|
||||
viewer,
|
||||
screenSpaceEventHandlerFactory,
|
||||
name,
|
||||
idPrefix,
|
||||
colorOf,
|
||||
}) {
|
||||
const dataSource = new Cesium.CustomDataSource(name);
|
||||
dataSource.show = false;
|
||||
viewer.dataSources.add(dataSource);
|
||||
return {
|
||||
setVisible(show) {
|
||||
dataSource.show = show;
|
||||
},
|
||||
pick(click) {
|
||||
return viewer.scene.pick(click.position);
|
||||
},
|
||||
onClick(callback) {
|
||||
const handler = screenSpaceEventHandlerFactory(viewer);
|
||||
handler.setInputAction(callback, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
return () => handler.destroy();
|
||||
},
|
||||
replace(rows) {
|
||||
const nextEntities = [];
|
||||
for (const row of rows) {
|
||||
const color = Cesium.Color.fromCssColorString(colorOf(row));
|
||||
for (const [index, rings] of row.polygons.entries()) {
|
||||
const [outer, ...holes] = rings;
|
||||
const outerPositions = ringPositions(outer);
|
||||
nextEntities.push(
|
||||
new Cesium.Entity({
|
||||
id: `${idPrefix}:${row.stableId}:${index}`,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
outerPositions,
|
||||
holes.map(
|
||||
(hole) => new Cesium.PolygonHierarchy(ringPositions(hole)),
|
||||
),
|
||||
),
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.25),
|
||||
),
|
||||
},
|
||||
// Ground-clamped outline (entity polygons cannot outline
|
||||
// clamped geometry themselves).
|
||||
polyline: {
|
||||
positions: outerPositions,
|
||||
clampToGround: true,
|
||||
width: 2,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.9),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
dataSource.entities.removeAll();
|
||||
for (const entity of nextEntities) dataSource.entities.add(entity);
|
||||
},
|
||||
destroy() {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createNavaraPolygonDisplay } from './navaraPolygons.js';
|
||||
import { containsGroundPoint } from './groundRing.js';
|
||||
|
||||
/**
|
||||
* Ground zones on Navara, as cesiumZones.js draws them on Cesium: each row's
|
||||
* polygons as a translucent fill under a ground-clamped outline in the row's
|
||||
* CSS colour (`colorOf(row)`), picked by a ground point inside the outline
|
||||
* and outside its holes (`{ id: '${idPrefix}:${stableId}:${index}' }`).
|
||||
*/
|
||||
export function createZonesNavaraRenderer(
|
||||
engine,
|
||||
{ view },
|
||||
{ idPrefix, colorOf },
|
||||
) {
|
||||
let polygons = [];
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
|
||||
polygon: {
|
||||
color,
|
||||
show,
|
||||
clampToGround: true,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
},
|
||||
polyline: {
|
||||
color,
|
||||
show,
|
||||
clampToGround: true,
|
||||
width: 2,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
},
|
||||
}));
|
||||
return {
|
||||
setVisible: display.setVisible,
|
||||
replace(rows) {
|
||||
const groups = new Map(),
|
||||
next = [];
|
||||
for (const row of rows) {
|
||||
const color = colorOf(row);
|
||||
if (!groups.has(color)) groups.set(color, []);
|
||||
for (const [index, rings] of row.polygons.entries()) {
|
||||
const id = `${idPrefix}:${row.stableId}:${index}`;
|
||||
const properties = { zone: row.stableId };
|
||||
groups.get(color).push(
|
||||
{
|
||||
type: 'Feature',
|
||||
id,
|
||||
properties,
|
||||
geometry: { type: 'Polygon', coordinates: rings },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: id + '-outline',
|
||||
properties,
|
||||
geometry: { type: 'LineString', coordinates: rings[0] },
|
||||
},
|
||||
);
|
||||
next.push({ id, rings });
|
||||
}
|
||||
}
|
||||
display.replace(groups);
|
||||
polygons = next;
|
||||
},
|
||||
onClick(callback) {
|
||||
// Zones never claim a sibling pick. The input port shares the native
|
||||
// click result with other consumers of the same gesture.
|
||||
return engine.input.on('click', callback, { pick: true });
|
||||
},
|
||||
pick(event) {
|
||||
if (event.picked) return event.picked;
|
||||
const point = engine.picker.pickGlobe(event.position);
|
||||
if (!point) return null;
|
||||
const pos = geo.cartographic.fromCartesian(point);
|
||||
const lon = geo.math.toDegrees(pos.longitude),
|
||||
lat = geo.math.toDegrees(pos.latitude);
|
||||
for (let i = polygons.length - 1; i >= 0; i--) {
|
||||
const { id, rings } = polygons[i];
|
||||
if (
|
||||
containsGroundPoint(rings[0], lon, lat) &&
|
||||
!rings.slice(1).some((ring) => containsGroundPoint(ring, lon, lat))
|
||||
)
|
||||
return { id };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
destroy() {
|
||||
polygons = [];
|
||||
display.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user