From 11992828fbbc34cc0154c3eb8de892616ec75382 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 03:13:36 +0400 Subject: [PATCH] Keep GhostMaps KMZ editions and selections across native globe renderers --- scripts/fixtures/ghostmaps-scenes.kmz | Bin 0 -> 609 bytes scripts/package-boundaries.json | 11 ++ scripts/qa-ghostmaps-switch.mjs | 173 +++++++++++++++++ src/app/constructCatalog.js | 2 +- src/app/layers/ghostMaps.js | 2 + src/app/navaraLayers.test.mjs | 1 + src/app/navaraMode.js | 5 + src/layers/ghostMaps/index.js | 219 ++++++++-------------- src/layers/ghostMaps/lifecycle.test.mjs | 16 ++ src/layers/ghostMaps/render/cesium.js | 142 ++++++++++++++ src/layers/ghostMaps/render/navara.js | 138 ++++++++++++++ src/layers/ghostMaps/render/navaraPick.js | 122 ++++++++++++ src/layers/perimeters/render/navara.js | 22 +-- src/layers/shared/render/groundRing.js | 17 ++ 14 files changed, 709 insertions(+), 161 deletions(-) create mode 100644 scripts/fixtures/ghostmaps-scenes.kmz create mode 100644 scripts/qa-ghostmaps-switch.mjs create mode 100644 src/layers/ghostMaps/render/cesium.js create mode 100644 src/layers/ghostMaps/render/navara.js create mode 100644 src/layers/ghostMaps/render/navaraPick.js create mode 100644 src/layers/shared/render/groundRing.js diff --git a/scripts/fixtures/ghostmaps-scenes.kmz b/scripts/fixtures/ghostmaps-scenes.kmz new file mode 100644 index 0000000000000000000000000000000000000000..e0fc2cfd104145c7cd3fe57a100d8586bb79ce19 GIT binary patch literal 609 zcmWIWW@Zs#U|`^2V3kacy|?UhuLmOoLm@K*13OSOB|lj&J2z))aG-y)fk55&@Cmls z?FMbLHg_JIu4?D=mz7t0jbx1H%cjrQy&1PNbsyVzr*`N2s{7@>h4Etd(jx=@?zkZt zY|CKc`*^iifL`|emd0s}3+44B_O195z-9D?%beBU_2k!iiXVeEY*$}DspSIWAA{qI zcwdKHoN!Gu-?`(0dTi%IzwVx`wRNtRPVEA}ge=Y)9O<3(w!mlVrD)GP9kcGLe6K4h z-`n#iwC6#X0GrjEt#Mb@UOm{+`6=8p$s;&aCddLh4QQC{2wONEj$J2vI0 z53ASASxR^K9Ufcdg8h0d8YG5>!+m@ z*l3;(-~25{EI_n*`Re(nt(Lc_F$8!sGKnzbj$)w2V9)@Jc6fxNYeIJ_L<<8$17j{w W8`R+e-mGjO2__)S0@4q`CISG8?d7Tf literal 0 HcmV?d00001 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5c9ff552c..b4bceed86 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3238,6 +3238,7 @@ "src/layers/gnss/records.js", "src/app/layers/ghostMaps.js", "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/render/cesium.js", "src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", @@ -3671,6 +3672,9 @@ "src/layers/earthquakes/render/navaraGrid.js", "src/layers/installations/render/navara.js", "src/layers/awareness/render/navara.js", + "src/layers/ghostMaps/render/navara.js", + "src/layers/ghostMaps/render/navaraPick.js", + "src/layers/shared/render/groundRing.js", "src/layers/bikeshare/render/navara.js", "src/layers/installations/render/navaraGeometry.js", "src/layers/installations/render/navaraOutlineBatch.js", @@ -4124,6 +4128,7 @@ "src/layers/gnss/records.js", "src/app/layers/ghostMaps.js", "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/render/cesium.js", "src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", @@ -5324,10 +5329,16 @@ "exports": ["./layers/ghostmaps"], "modules": [ "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/render/cesium.js", "src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/source.js", + "src/geo/cartesian3.js", + "src/geo/check.js", + "src/geo/debug.js", + "src/geo/developerError.js", + "src/geo/math.js", "src/sources/httpBody.js", "src/data/geoFileImport.js", "src/i18n/marker.js", diff --git a/scripts/qa-ghostmaps-switch.mjs b/scripts/qa-ghostmaps-switch.mjs new file mode 100644 index 000000000..d6542da7d --- /dev/null +++ b/scripts/qa-ghostmaps-switch.mjs @@ -0,0 +1,173 @@ +import { fromDegrees } from '../src/geo/cartesian3.js'; +import puppeteer from 'puppeteer'; +import { readFile } from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; + +const base = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) + throw new Error('Supply an unreserved local keyless preview URL'); +base.search = '?engine=navara&welcome=0&look=cinematic'; +base.hash = + 'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1024, height: 768 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + const file = await readFile( + new URL('./fixtures/ghostmaps-scenes.kmz', import.meta.url), + ); + await p.evaluateOnNewDocument( + (bytes) => { + const fetch = window.fetch.bind(window); + const events = window.EventSource; + window.ghostStats = { streams: 0, reads: 0, catalogs: 0, titles: [] }; + window.EventSource = class extends EventTarget { + constructor(url, options) { + super(); + if (!url.includes('/api/ghostmaps/events')) + return new events(url, options); + window.ghostStats.streams++; + this.readyState = 1; + } + close() {} + }; + window.fetch = (input, options) => { + const { pathname } = new URL(input.url ?? input, document.baseURI); + if (pathname === '/api/ghostmaps/catalog') { + window.ghostStats.catalogs++; + return Promise.resolve( + Response.json({ + commit: 'qa', + products: [ + { id: 'cip', version: 'qa', title: 'QA', defaultOn: true }, + ], + }), + ); + } + if (pathname.startsWith('/api/ghostmaps/file/')) { + window.ghostStats.reads++; + return Promise.resolve(new Response(new Uint8Array(bytes))); + } + return fetch(input, options); + }; + const fill = CanvasRenderingContext2D.prototype.fillText; + CanvasRenderingContext2D.prototype.fillText = function (value, ...args) { + if (/QA (pin|route|zone)/i.test(value)) + window.ghostStats.titles.push(value); + return fill.call(this, value, ...args); + }; + }, + [...file], + ); + await p.goto(base.href); + const ready = async (kind) => { + await p.waitForFunction( + (k) => + window.__godsEyeView?.engine.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + kind, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + await p.evaluate(async () => { + const { dataManager } = window.__godsEyeView; + await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' }); + window.qaGhost = dataManager.layers.get('s2-ghostmaps').module; + }); + const click = async (lon, lat) => { + const { x, y } = await p.evaluate( + (pos) => { + const { engine } = window.__godsEyeView; + const point = engine.projector.worldToWindow(pos, {}); + const box = engine.host.canvas.getBoundingClientRect(); + window.ghostStats.titles = []; + return { x: box.left + point.x, y: box.top + point.y }; + }, + fromDegrees(lon, lat), + ); + await p.mouse.click(x, y); + }; + for (const kind of ['navara', 'cesium', 'navara']) { + if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { + await p.evaluate(() => { + window.ghostStats.titles = []; + }); + await p.select('#globe-engine', kind); + await ready(kind); + await p.waitForFunction(() => + window.ghostStats.titles.some((v) => /QA zone/i.test(v)), + ); + } + const inventory = await p.evaluate(() => ({ + same: + window.qaGhost === + window.__godsEyeView.dataManager.layers.get('s2-ghostmaps').module, + names: window.qaGhost.getAnalystRecords().map((r) => r.name), + streams: window.ghostStats.streams, + reads: window.ghostStats.reads, + })); + assert.deepEqual(inventory, { + same: true, + names: ['QA pin', 'QA route', 'QA zone'], + streams: 1, + reads: 1, + }); + await new Promise((resolve) => setTimeout(resolve, 2200)); + for (const [name, lon, lat] of [ + ['QA pin', -79.38, 43.65], + ['QA route', -79.375, 43.71], + ['QA zone', -79.28, 43.61], + ]) { + await click(lon, lat); + await p.waitForFunction( + (name) => + window.ghostStats.titles.some((v) => + v.toLowerCase().includes(name.toLowerCase()), + ), + { timeout: 12000 }, + name, + ); + console.log('PASS', kind, name, 'pointer hit'); + } + await click(-79.316, 43.61); + await new Promise((resolve) => setTimeout(resolve, 600)); + await p.evaluate(() => { + window.ghostStats.titles = []; + window.__godsEyeView.engine.render.request('qa-hole'); + }); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.equal( + await p.evaluate(() => window.ghostStats.titles.length), + 0, + kind + ' polygon hole', + ); + await click(-79.28, 43.61); + await p.waitForFunction(() => + window.ghostStats.titles.some((v) => /QA zone/i.test(v)), + ); + } + assert.deepEqual(errors, []); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 26de4c459..2bbc0f7fb 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -207,7 +207,7 @@ export function createApplicationCatalog({ ], [ 's2-ghostmaps', - () => createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), + () => sessionModel('s2-ghostmaps', () => createApplicationGhostMaps({ source: sources['s2-ghostmaps'] })), ], [ 'alpr-cameras', diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index ccca18850..6adbb0552 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { rendererFor } from './renderers.js'; import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; @@ -18,6 +19,7 @@ export function createApplicationGhostMaps(options) { new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), picking, pointer: { isPointerFree }, + createRenderer: (engine) => rendererFor('s2-ghostmaps', engine), ...options, }); } diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 6721172f3..31f844812 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -50,6 +50,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'air-quality', 'fire-perimeters', 'gnss-integrity', + 's2-ghostmaps', 'alpr-cameras', 'satellites', 'rocket-launches', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index a6adfe6ba..6439ce2fa 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -58,6 +58,7 @@ const NAVARA_CREDIT_KEYS = new Set([ 'usgs', 'eccc-aqhi', 'wfigs', + 's2-ghostmaps', 'firms', 'gbfs', 'overture-military-names', @@ -106,6 +107,10 @@ export async function registerNavaraLayerRenderers() { bikeshare: async () => (await import('../layers/bikeshare/render/navara.js')) .createBikeshareNavaraRenderer, + 's2-ghostmaps': async () => { + const { createGhostMapsNavaraRenderer } = await import('../layers/ghostMaps/render/navara.js'); + return (engine) => createGhostMapsNavaraRenderer(engine, navaraViews.get(engine).view); + }, 'military-awareness': async () => (await import('../layers/awareness/render/navara.js')).createAwarenessNavaraRenderer, 'military-installations': async () => { diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 01d564d05..7459a5791 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import { fromDegrees } from '../../geo/cartesian3.js'; +import { createGhostMapsCesiumRenderer } from './render/cesium.js'; import { decodeXml, kmzImageResolver, @@ -14,7 +15,6 @@ import { t, tn, } from '../../i18n/index.js'; -import { createHorizonCull } from '../streetLevel/view.js'; import { readGhostMapsKml } from './kml.js'; import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js'; export * from './catalog.js'; @@ -106,11 +106,6 @@ function markerImage(color) { return url; } -const cesiumColor = (rgba, fallback, alpha) => - rgba - ? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3]) - : Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1); - function anchorOf(geometry) { if (geometry.type === 'point') return geometry.position; const ring = @@ -133,6 +128,7 @@ export function createGhostMapsLayer({ source, overlayHost = null, screenSpaceEventHandlerFactory = null, + createRenderer = null, picking = null, pointer = null, openExternal = null, @@ -140,7 +136,7 @@ export function createGhostMapsLayer({ } = {}) { if (typeof source?.getCatalog !== 'function') throw new TypeError('GhostMaps layer requires a catalog source'); - let _viewer = null; + let _display = null; let _enabled = false; let _catalog = null; let _lastError = null; @@ -154,7 +150,7 @@ export function createGhostMapsLayer({ let _selectedLink = null; let _lifetime = new AbortController(); let _selection = readStoredSelection(storage); - /** productId → {version, dataSource, urls, count, features} */ + /** Product editions own the decoded features and their packed image URLs. */ const _loaded = new Map(); /** productId → in-flight version */ const _loading = new Map(); @@ -162,18 +158,8 @@ export function createGhostMapsLayer({ const _failed = new Map(); const notifyRow = () => _rowListener?.(); - const render = () => _viewer?.scene?.requestRender?.(); - // Pins skip the depth test, and with Google 3D tiles the globe is hidden, - // so nothing else stops far-side pins drawing through the planet. - const _horizon = createHorizonCull({ - getViewer: () => _viewer, - items: function* () { - for (const item of _loaded.values()) yield* item.pins; - }, - onChange: render, - }); - const canSelect = () => - overlayHost && screenSpaceEventHandlerFactory && picking; + const render = () => _display?.requestRender(); + const canSelect = () => overlayHost && picking && (screenSpaceEventHandlerFactory || createRenderer); function wanted(product) { return _selection ? _selection.has(product.id) : product.defaultOn; @@ -187,12 +173,13 @@ export function createGhostMapsLayer({ const item = _loaded.get(id); if (!item) return; _loaded.delete(id); - _viewer?.dataSources?.remove(item.dataSource, true); + item.removed = true; + item.paint?.destroy(); item.urls.forEach((url) => URL.revokeObjectURL(url)); if (_selectedKey?.startsWith(`${id}:`)) clearSelection(); } - /** Build one product's data source off-scene, then swap it in. */ + /** Decode one product without a scene. */ async function load(product, signal) { const bytes = await source.getFile(product.id, product.version, { signal }); signal.throwIfAborted(); @@ -219,117 +206,41 @@ export function createGhostMapsLayer({ ); entries.clear(); signal.throwIfAborted(); - const color = PRODUCT_COLORS[product.id] || ACCENT; - const dataSource = new Cesium.CustomDataSource( - `${GHOSTMAPS_LAYER_ID}:${product.id}`, - ); - const features = []; - const pins = []; - dataSource.entities.suspendEvents(); - parsed.features.forEach((feature, index) => { - const key = `${product.id}:${index}`; - const { style } = feature; - feature.geometries.forEach((geometry, part) => { - const id = `${PICK_PREFIX}${key}:${part}`; - if (geometry.type === 'point') { - const position = Cesium.Cartesian3.fromDegrees( - ...geometry.position, - ); - const entity = dataSource.entities.add({ - id, - position, - billboard: { - image: style.icon || markerImage(color), - scale: Math.min( - 1.5, - Math.max(0.35, (style.iconScale ?? 1) * 0.7), - ), - color: - style.icon && style.iconColor - ? cesiumColor(style.iconColor) - : undefined, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - // Ground-clamped icons would sink into terrain and 3D tiles; the - // horizon cull hides the far side instead (see `_horizon`). - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - pins.push({ - position, - get show() { - return entity.show; - }, - set show(value) { - entity.show = value; - }, - }); - } else if (geometry.type === 'line') { - dataSource.entities.add({ - id, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArray( - geometry.positions.flat(), - ), - width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), - material: cesiumColor(style.lineColor, color), - clampToGround: true, - }, - }); - } else { - const ring = (points) => - Cesium.Cartesian3.fromDegreesArray(points.flat()); - dataSource.entities.add({ - id, - polygon: { - hierarchy: new Cesium.PolygonHierarchy( - ring(geometry.outer), - geometry.holes.map( - (hole) => new Cesium.PolygonHierarchy(ring(hole)), - ), - ), - material: - style.fill === false - ? Cesium.Color.TRANSPARENT - : cesiumColor( - style.polyColor, - color, - style.polyColor ? undefined : 0.25, - ), - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - if (style.outline !== false) - dataSource.entities.add({ - id: `${id}:outline`, - polyline: { - positions: ring([...geometry.outer, geometry.outer[0]]), - width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), - material: cesiumColor(style.lineColor, color), - clampToGround: true, - }, - }); - } - }); - features.push({ - name: feature.name, - folder: feature.folder, - description: feature.description, - anchor: anchorOf(feature.geometries[0]), - }); - }); - dataSource.entities.resumeEvents(); - return { version: product.version, dataSource, urls, features, pins }; + const features = parsed.features.map((feature) => ({ + ...feature, anchor: anchorOf(feature.geometries[0]), + })); + return { version: product.version, urls, features, paint: null }; } catch (error) { urls.forEach((url) => URL.revokeObjectURL(url)); throw error; } } + async function paint(id, item, signal) { + while (_display && !signal.aborted && !item.removed) { + const owner = _display; + const next = owner.draw(id, item.features, PRODUCT_COLORS[id] || ACCENT, markerImage); + try { + await next.ready; + if (owner === _display && !signal.aborted && !item.removed) { + item.paint = next; + return; + } + } catch (error) { + next.destroy(); + if (owner === _display && !signal.aborted && !item.removed) throw error; + if (_loaded.get(id) === item) return; + continue; + } + next.destroy(); + if (_loaded.get(id) === item) return; + } + } + /** Bring the globe in line with the catalog and the chip selection. */ function sync() { const run = _queue.then(async () => { - if (!_enabled || !_viewer || !_catalog) return; + if (!_enabled || !_catalog) return; const signal = _lifetime.signal; const products = new Map(_catalog.products.map((p) => [p.id, p])); for (const id of [..._loaded.keys()]) { @@ -348,9 +259,17 @@ export function createGhostMapsLayer({ next.urls.forEach((url) => URL.revokeObjectURL(url)); return; } - // Cull before the first frame so far-side pins never flash. - _horizon.update(next.pins); - await _viewer.dataSources.add(next.dataSource); + try { + await paint(product.id, next, signal); + } catch (error) { + next.urls.forEach((url) => URL.revokeObjectURL(url)); + throw error; + } + if (signal.aborted) { + next.paint?.destroy(); + next.urls.forEach((url) => URL.revokeObjectURL(url)); + return; + } // The new edition is on the globe before the old one leaves. unload(product.id); _loaded.set(product.id, next); @@ -457,7 +376,7 @@ export function createGhostMapsLayer({ // when its marker turns behind the planet. stateless: true, horizonCull: true, - position: Cesium.Cartesian3.fromDegrees(...feature.anchor), + position: fromDegrees(...feature.anchor), }; if (card.interactive) { const link = _selectedLink; @@ -496,14 +415,13 @@ export function createGhostMapsLayer({ } function installClickHandler() { - if (!canSelect() || _clickHandler || !_viewer) return; + if (!canSelect() || _clickHandler || !_display) return; picking.registerPickOwner?.( GHOSTMAPS_LAYER_ID, (pickedId) => typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), ); - _clickHandler = screenSpaceEventHandlerFactory(_viewer); - _clickHandler.setInputAction((click) => { + _clickHandler = _display.onClick((click, pick) => { if (pointer && !pointer.isPointerFree()) return; const cardHit = overlayHost.hitTest?.( click.position?.x, @@ -514,7 +432,7 @@ export function createGhostMapsLayer({ if (_selectedLink && openExternal) openExternal(_selectedLink); return; } - const picked = _viewer.scene.pick(click.position); + const picked = pick(); const key = picked ? pickedKey(picked) : null; if (key) { _selectedKey = key; @@ -531,11 +449,11 @@ export function createGhostMapsLayer({ clearSelection(); render(); } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); } function removeClickHandler() { - _clickHandler?.destroy(); + _clickHandler?.(); _clickHandler = null; picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID); } @@ -555,15 +473,35 @@ export function createGhostMapsLayer({ // the backstop if the stream is down. updateInterval: 300000, - init(viewer) { - if (_viewer) throw new Error('GhostMaps layer is already initialized'); - _viewer = viewer; + init(viewer, options = {}) { + if (viewer || options.engine) return this.attachRenderer(viewer, options); + }, + + attachRenderer(viewer, { engine = null } = {}) { + if (_display) throw new Error('GhostMaps display is already attached'); + _display = viewer + ? createGhostMapsCesiumRenderer(viewer, screenSpaceEventHandlerFactory, markerImage) + : createRenderer(engine); + if (!_enabled) return; + installClickHandler(); + publishCard(); + return Promise.all([..._loaded].map(([id, item]) => paint(id, item, _lifetime.signal))); + }, + + detachRenderer() { + removeClickHandler(); + for (const item of _loaded.values()) { + item.paint?.destroy(); + item.paint = null; + } + overlayHost?.clearSource?.(GHOSTMAPS_OVERLAY_SOURCE_ID); + _display?.destroy(); + _display = null; }, enable() { _enabled = true; _lifetime = new AbortController(); - for (const item of _loaded.values()) item.dataSource.show = true; installClickHandler(); if (!_unsubscribe && typeof source.subscribe === 'function') _unsubscribe = source.subscribe( @@ -586,7 +524,6 @@ export function createGhostMapsLayer({ _stream = 'closed'; removeClickHandler(); clearSelection(); - _horizon.stop(); for (const id of [..._loaded.keys()]) unload(id); render(); }, @@ -610,7 +547,7 @@ export function createGhostMapsLayer({ destroy() { layer.disable(); - _viewer = null; + layer.detachRenderer(); _catalog = null; _rowListener = null; }, diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs index 51d546017..4d5c43c21 100644 --- a/src/layers/ghostMaps/lifecycle.test.mjs +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -155,6 +155,22 @@ test('cancellation during parsing releases every packed-image URL', async (t) => assert.equal(mounted.size, 0); }); +test('a KMZ delivered between planets is reused by the new scene and releases its icon on disable', async (t) => { + let layer; + const h = fixture(t, { beforeFileReturns() { layer.detachRenderer(); } }); + layer = h.layer; + await layer.update(); + assert.deepEqual(layer.getAnalystRecords().map(r => r.name), ['Test report']); + assert.equal(h.mounted.size, 0); + await layer.attachRenderer(h.viewer); + assert.equal(h.mounted.size, 1); + assert.equal(h.created.length, 1); + assert.equal(h.revoked.length, 0); + layer.disable(); + assert.deepEqual(h.revoked, h.created); + assert.equal(h.mounted.size, 0); +}); + test('entity construction failure releases parsed image URLs', async (t) => { const { layer, viewer, mounted, created, revoked } = fixture(t); t.mock.method(Cesium.EntityCollection.prototype, 'add', () => { diff --git a/src/layers/ghostMaps/render/cesium.js b/src/layers/ghostMaps/render/cesium.js new file mode 100644 index 000000000..29fb49574 --- /dev/null +++ b/src/layers/ghostMaps/render/cesium.js @@ -0,0 +1,142 @@ +import * as Cesium from 'cesium'; +import { createHorizonCull } from '../../streetLevel/view.js'; + +const cesiumColor = (rgba, fallback, alpha) => + rgba + ? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3]) + : Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1); + +export function createGhostMapsCesiumRenderer( + viewer, + createHandler, + markerImage, +) { + const allPins = new Set(); + const horizon = createHorizonCull({ + getViewer: () => viewer, + items: () => allPins, + onChange: () => viewer.scene.requestRender?.(), + }); + return { + requestRender: () => viewer.scene.requestRender?.(), + onClick(run) { + const handler = createHandler(viewer); + handler.setInputAction( + (click) => run(click, () => viewer.scene.pick(click.position)), + Cesium.ScreenSpaceEventType.LEFT_CLICK, + ); + return () => handler.destroy(); + }, + draw(productId, features, color) { + const dataSource = new Cesium.CustomDataSource( + `s2-ghostmaps:${productId}`, + ); + const pins = []; + dataSource.entities.suspendEvents(); + features.forEach((feature, index) => { + const key = `${productId}:${index}`; + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `ghostmaps:${key}:${part}`; + if (geometry.type === 'point') { + const position = Cesium.Cartesian3.fromDegrees( + ...geometry.position, + ); + const entity = dataSource.entities.add({ + id, + position, + billboard: { + image: style.icon || markerImage(color), + scale: Math.min( + 1.5, + Math.max(0.35, (style.iconScale ?? 1) * 0.7), + ), + color: + style.icon && style.iconColor + ? cesiumColor(style.iconColor) + : undefined, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + // Ground-clamped icons would sink into terrain and 3D tiles; the + // horizon cull hides the far side instead (see `_horizon`). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + pins.push({ + position, + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + }); + } else if (geometry.type === 'line') { + dataSource.entities.add({ + id, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }); + if (style.outline !== false) + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } + }); + }); + dataSource.entities.resumeEvents(); + for (const pin of pins) allPins.add(pin); + horizon.update(pins); + let disposed = false; + const ready = Promise.resolve(viewer.dataSources.add(dataSource)).finally( + () => { + if (disposed) viewer.dataSources.remove(dataSource, true); + }, + ); + return { + ready, + destroy() { + disposed = true; + for (const pin of pins) allPins.delete(pin); + viewer.dataSources.remove(dataSource, true); + }, + }; + }, + destroy: horizon.stop, + }; +} diff --git a/src/layers/ghostMaps/render/navara.js b/src/layers/ghostMaps/render/navara.js new file mode 100644 index 000000000..2a851f6df --- /dev/null +++ b/src/layers/ghostMaps/render/navara.js @@ -0,0 +1,138 @@ +import { bindProductPicks } from './navaraPick.js'; + +const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); + +function tint(rgba, fallback) { + if (!rgba) return parseInt(fallback.slice(1), 16); + return ( + (Math.round(rgba[0] * 255) << 16) | + (Math.round(rgba[1] * 255) << 8) | + Math.round(rgba[2] * 255) + ); +} + +function closedRing(ring) { + const last = ring.at(-1); + return last[0] === ring[0][0] && last[1] === ring[0][1] + ? ring + : [...ring, ring[0]]; +} + +/** Native vector batches preserve each KML style and carry the source feature ID in picks. */ +export function createGhostMapsNavaraRenderer(engine, view) { + return { + requestRender: () => engine.render.request('s2-ghostmaps'), + onClick: (run) => + engine.input.on('click', (event) => run(event, () => event.picked), { + pick: true, + }), + draw(productId, features, fallback, markerImage) { + const groups = new Map(); + function add(style, geom, id) { + const key = JSON.stringify(style); + if (!groups.has(key)) groups.set(key, { style, features: [] }); + groups + .get(key) + .features.push({ + type: 'Feature', + geometry: geom, + properties: { gevId: id }, + }); + } + for (const [index, feature] of features.entries()) { + const { style } = feature; + const line = { + color: tint(style.lineColor, fallback), + opacity: style.lineColor?.[3] ?? 1, + width: clamp(style.lineWidth ?? 2, 1.5, 8), + transparent: true, + clampToGround: true, + }; + for (const [part, geometry] of feature.geometries.entries()) { + const id = `ghostmaps:${productId}:${index}:${part}`; + if (geometry.type === 'point') { + const color = + style.iconColor && style.icon ? style.iconColor : null; + const billboard = { + url: style.icon || markerImage(fallback), + sizeInMeters: false, + size: 20 * clamp((style.iconScale ?? 1) * 0.7, 0.35, 1.5), + color: tint(color, '#ffffff'), + opacity: color?.[3] ?? 1, + clampToGround: true, + declutter: false, + offsetDepth: true, + }; + add( + { billboard }, + { type: 'Point', coordinates: geometry.position }, + id, + ); + } else if (geometry.type === 'line') { + add( + { polyline: line }, + { type: 'LineString', coordinates: geometry.positions }, + id, + ); + } else { + const outer = closedRing(geometry.outer); + if (style.fill !== false) + add( + { + polygon: { + color: tint(style.polyColor, fallback), + opacity: style.polyColor?.[3] ?? 0.25, + transparent: true, + clampToGround: true, + }, + }, + { + type: 'Polygon', + coordinates: [outer, ...geometry.holes.map(closedRing)], + }, + id, + ); + if (style.outline !== false) + add( + { polyline: { ...line, width: Math.min(6, line.width) } }, + { type: 'LineString', coordinates: outer }, + id, + ); + } + } + } + const batches = []; + let unbind; + const destroy = () => { + unbind?.(); + for (const batch of batches.splice(0)) { + batch.layer?.delete(); + batch.source.delete(); + } + engine.render.request('ghostmaps-release'); + }; + try { + for (const group of groups.values()) { + const source = view.addSource({ + type: 'geojson', + data: { type: 'FeatureCollection', features: group.features }, + }); + const batch = { source }; + batches.push(batch); + batch.layer = view.addLayer({ + type: 'vector', + source, + ...group.style, + }); + } + unbind = bindProductPicks(engine, productId, features); + } catch (error) { + destroy(); + throw error; + } + engine.render.request('ghostmaps-data'); + return { destroy }; + }, + destroy() {}, + }; +} diff --git a/src/layers/ghostMaps/render/navaraPick.js b/src/layers/ghostMaps/render/navaraPick.js new file mode 100644 index 000000000..378c83762 --- /dev/null +++ b/src/layers/ghostMaps/render/navaraPick.js @@ -0,0 +1,122 @@ +import * as geo from '../../../geo/index.js'; +import { distanceToSegment } from '../../../globe/cpuPicks.js'; +import { containsGroundPoint } from '../../shared/render/groundRing.js'; + +function groundScreen(engine, [lon, lat]) { + const point = geo.cartographic.fromDegrees(lon, lat); + const height = engine.surface.terrainHeight(point) ?? 0; + const position = geo.cartesian3.fromDegrees(lon, lat, height); + return ( + engine.projector.horizonVisible(position) && + engine.projector.worldToWindow(position, {}) + ); +} + +// The GPU may return the preceding gesture's vector feature. Resolve against +// the current gesture and let the engine arbitrate the result with sibling layers. +export function bindProductPicks(engine, productId, features) { + const regions = []; + for (const [index, feature] of features.entries()) { + for (const [part, geometry] of feature.geometries.entries()) { + const coords = + geometry.type === 'point' + ? [geometry.position] + : geometry.type === 'line' + ? geometry.positions + : geometry.outer; + const west = coords[0][0]; + const local = (lon) => lon - west - 360 * Math.round((lon - west) / 360); + let south = 90, + north = -90, + min = Infinity, + max = -Infinity; + for (const [lon, lat] of coords) { + south = Math.min(south, lat); + north = Math.max(north, lat); + min = Math.min(min, local(lon)); + max = Math.max(max, local(lon)); + } + regions.push({ + geometry, + style: feature.style, + coords, + south, + north, + min, + max, + local, + id: `ghostmaps:${productId}:${index}:${part}`, + }); + } + } + const pick = (at) => { + const world = engine.picker.pickGlobe(at); + if (!world) return []; + const carto = geo.cartographic.fromCartesian(world); + const lon = geo.math.toDegrees(carto.longitude), + lat = geo.math.toDegrees(carto.latitude); + const depth = geo.cartesian3.distance(world, engine.camera.livePosition); + const pose = engine.camera.pose(); + // Conservative geographic margin for a displaced terrain pixel/marker at this zoom. + const margin = + (2 * Math.tan(pose.fovy / 2) * depth * 32) / + Math.max(1, engine.host.size().height) / + 110000; + const lonMargin = margin / Math.max(0.00001, Math.cos(carto.latitude)); + const hits = []; + for (const region of regions) { + const x = region.local(lon); + if ( + lat < region.south - margin || + lat > region.north + margin || + (region.max - region.min < 180 && + (x < region.min - lonMargin || x > region.max + lonMargin)) + ) + continue; + const { geometry, style, coords } = region; + let hit = false; + if (geometry.type === 'point') { + const screen = groundScreen(engine, geometry.position); + const size = + 10 * Math.min(1.5, Math.max(0.35, (style.iconScale ?? 1) * 0.7)); + hit = + screen && + Math.abs(at.x - screen.x) <= size && + Math.abs(at.y - screen.y) <= size; + } else { + if (geometry.type === 'polygon' && style.fill !== false) + hit = + containsGroundPoint(coords, lon, lat) && + !geometry.holes.some((ring) => containsGroundPoint(ring, lon, lat)); + if (!hit && (geometry.type === 'line' || style.outline !== false)) { + let last = + geometry.type === 'polygon' + ? groundScreen(engine, coords.at(-1)) + : null; + for (const coord of coords) { + const next = groundScreen(engine, coord); + if ( + last && + next && + distanceToSegment(at, last, next) <= + Math.max(2, Math.min(8, style.lineWidth ?? 2) / 2) + ) { + hit = true; + break; + } + last = next; + } + } + } + if (hit) + hits.push({ + onTop: 0, + order: geometry.type === 'point' ? 1 : 0, + depth, + result: { id: region.id, owner: 's2-ghostmaps' }, + }); + } + return hits; + }; + return engine.picker.registerMeshPicker(pick); +} diff --git a/src/layers/perimeters/render/navara.js b/src/layers/perimeters/render/navara.js index 488066a1f..989caf66c 100644 --- a/src/layers/perimeters/render/navara.js +++ b/src/layers/perimeters/render/navara.js @@ -2,23 +2,7 @@ import * as geo from '../../../geo/index.js'; import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js'; import { containmentAccent } from '../cards.js'; -// A fire ring is local, including across 180°. Put the click and ring in -// the same longitude interval before casting the ray. -function contains(ring, lon, lat) { - const origin = ring[0][0]; - const local = (x) => x - origin - 360 * Math.round((x - origin) / 360); - const x = local(lon); - let inside = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const [ax, ay] = ring[j], - [bx, by] = ring[i]; - if (ay > lat === by > lat) continue; - const a = local(ax), - b = local(bx); - if (x < a + ((b - a) * (lat - ay)) / (by - ay)) inside = !inside; - } - return inside; -} +import { containsGroundPoint } from '../../shared/render/groundRing.js'; export function createPerimetersNavaraRenderer(engine, { view }) { let polygons = []; @@ -86,8 +70,8 @@ export function createPerimetersNavaraRenderer(engine, { view }) { for (let i = polygons.length - 1; i >= 0; i--) { const { id, rings } = polygons[i]; if ( - contains(rings[0], lon, lat) && - !rings.slice(1).some((ring) => contains(ring, lon, lat)) + containsGroundPoint(rings[0], lon, lat) && + !rings.slice(1).some((ring) => containsGroundPoint(ring, lon, lat)) ) return { id }; } diff --git a/src/layers/shared/render/groundRing.js b/src/layers/shared/render/groundRing.js new file mode 100644 index 000000000..9fdf323db --- /dev/null +++ b/src/layers/shared/render/groundRing.js @@ -0,0 +1,17 @@ +// A geographic ring is local, including across 180°. Put the click and ring in +// the same longitude interval before casting the ray. +export function containsGroundPoint(ring, lon, lat) { + const origin = ring[0][0]; + const local = (x) => x - origin - 360 * Math.round((x - origin) / 360); + const x = local(lon); + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [ax, ay] = ring[j], + [bx, by] = ring[i]; + if (ay > lat === by > lat) continue; + const a = local(ax), + b = local(bx); + if (x < a + ((b - a) * (lat - ay)) / (by - ay)) inside = !inside; + } + return inside; +}