From 843c4a111d82ee02d5f41d606ad994a68f4afea7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 00:43:22 +0400 Subject: [PATCH] Show drone airspace, OSM places, sea marks and ski pistes on Navara, and keep them across engine switches --- scripts/package-boundaries.json | 25 ++++ src/app/constructCatalog.js | 21 +-- src/app/constructCatalog.test.mjs | 25 +--- src/app/layers/droneAirspace.js | 16 ++- src/app/layers/osmPlaces.js | 14 +- src/app/layers/renderers.js | 5 + src/app/layers/renderers.test.mjs | 2 + src/app/loadCesium.test.mjs | 2 +- src/app/navaraLayers.test.mjs | 4 + src/app/navaraMode.js | 12 ++ src/data/osmOverlays.js | 51 ++++++- src/data/osmOverlays.test.mjs | 30 ++++ src/globe/navara/maps.js | 42 ++++++ src/globe/navara/maps.test.mjs | 37 +++++ src/layers/droneAirspace/index.js | 144 +++++++++++--------- src/layers/droneAirspace/ownership.test.mjs | 68 +++++++++ src/layers/droneAirspace/render/navara.js | 14 ++ src/layers/osmPlaces/index.js | 138 ++++++++++++------- src/layers/osmPlaces/model.js | 2 + src/layers/osmPlaces/render/navara.js | 101 ++++++++++++++ src/layers/osmPlaces/render/navara.test.mjs | 55 ++++++++ src/layers/perimeters/render/cesium.js | 68 ++------- src/layers/perimeters/render/navara.js | 88 ++---------- src/layers/shared/render/cesiumZones.js | 77 +++++++++++ src/layers/shared/render/navaraZones.js | 92 +++++++++++++ 25 files changed, 838 insertions(+), 295 deletions(-) create mode 100644 src/layers/droneAirspace/render/navara.js create mode 100644 src/layers/osmPlaces/render/navara.js create mode 100644 src/layers/osmPlaces/render/navara.test.mjs create mode 100644 src/layers/shared/render/cesiumZones.js create mode 100644 src/layers/shared/render/navaraZones.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c244ea378..d2a71274d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index eb6ae11c5..4d447b152 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.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'], () => diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 67fb7a718..698f69ad5 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -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); diff --git a/src/app/layers/droneAirspace.js b/src/app/layers/droneAirspace.js index f3472fec4..4fd942a30 100644 --- a/src/app/layers/droneAirspace.js +++ b/src/app/layers/droneAirspace.js @@ -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, diff --git a/src/app/layers/osmPlaces.js b/src/app/layers/osmPlaces.js index 08b0c7526..c5aba4cf8 100644 --- a/src/app/layers/osmPlaces.js +++ b/src/app/layers/osmPlaces.js @@ -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 }), + }); } diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index d94d6c3cd..f4eb55400 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -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, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 90717641c..36857dbfd 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -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', diff --git a/src/app/loadCesium.test.mjs b/src/app/loadCesium.test.mjs index bf7d0631f..3197d3d1a 100644 --- a/src/app/loadCesium.test.mjs +++ b/src/app/loadCesium.test.mjs @@ -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( diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index a1d894e77..fbc37a348 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 0fc3ba81a..bbe1af7ee 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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'), diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js index dc3de45fc..ef755371a 100644 --- a/src/data/osmOverlays.js +++ b/src/data/osmOverlays.js @@ -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; diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs index ee2792ff4..74194828a 100644 --- a/src/data/osmOverlays.test.mjs +++ b/src/data/osmOverlays.test.mjs @@ -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(); +}); diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index 97db86f55..ed96b1337 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -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); }, }; diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs index 3737af792..2b8c292af 100644 --- a/src/globe/navara/maps.test.mjs +++ b/src/globe/navara/maps.test.mjs @@ -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(); +}); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index d0eb5e60a..073db2456 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -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; diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs index e058834ff..724842ed4 100644 --- a/src/layers/droneAirspace/ownership.test.mjs +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -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); +}); diff --git a/src/layers/droneAirspace/render/navara.js b/src/layers/droneAirspace/render/navara.js new file mode 100644 index 000000000..8b57a9b0b --- /dev/null +++ b/src/layers/droneAirspace/render/navara.js @@ -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), + }, + ); +} diff --git a/src/layers/osmPlaces/index.js b/src/layers/osmPlaces/index.js index 389350d09..1f5209604 100644 --- a/src/layers/osmPlaces/index.js +++ b/src/layers/osmPlaces/index.js @@ -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; diff --git a/src/layers/osmPlaces/model.js b/src/layers/osmPlaces/model.js index 124303983..f55c6be8d 100644 --- a/src/layers/osmPlaces/model.js +++ b/src/layers/osmPlaces/model.js @@ -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; diff --git a/src/layers/osmPlaces/render/navara.js b/src/layers/osmPlaces/render/navara.js new file mode 100644 index 000000000..fc4777a97 --- /dev/null +++ b/src/layers/osmPlaces/render/navara.js @@ -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'); + }, + }; +} diff --git a/src/layers/osmPlaces/render/navara.test.mjs b/src/layers/osmPlaces/render/navara.test.mjs new file mode 100644 index 000000000..bd688b454 --- /dev/null +++ b/src/layers/osmPlaces/render/navara.test.mjs @@ -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); +}); diff --git a/src/layers/perimeters/render/cesium.js b/src/layers/perimeters/render/cesium.js index 585d0cae1..fa411e1f9 100644 --- a/src/layers/perimeters/render/cesium.js +++ b/src/layers/perimeters/render/cesium.js @@ -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), + }); } diff --git a/src/layers/perimeters/render/navara.js b/src/layers/perimeters/render/navara.js index 989caf66c..7730bba90 100644 --- a/src/layers/perimeters/render/navara.js +++ b/src/layers/perimeters/render/navara.js @@ -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(); - }, - }; + ); } diff --git a/src/layers/shared/render/cesiumZones.js b/src/layers/shared/render/cesiumZones.js new file mode 100644 index 000000000..e7ae65cdb --- /dev/null +++ b/src/layers/shared/render/cesiumZones.js @@ -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); + }, + }; +} diff --git a/src/layers/shared/render/navaraZones.js b/src/layers/shared/render/navaraZones.js new file mode 100644 index 000000000..fa43e3104 --- /dev/null +++ b/src/layers/shared/render/navaraZones.js @@ -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(); + }, + }; +}