Keep GhostMaps KMZ editions and selections across native globe renderers

This commit is contained in:
German Khokhlov committed 2026-11-03 03:13:36 +04:00
1 parent 87bbe14748
commit 11992828fb
14 files changed
+709 -161

No files matched your search

Binary file not shown.
+11
View File
@@ -3238,6 +3238,7 @@
"src/layers/gnss/records.js", "src/layers/gnss/records.js",
"src/app/layers/ghostMaps.js", "src/app/layers/ghostMaps.js",
"src/layers/ghostMaps/index.js", "src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/render/cesium.js",
"src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js", "src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/kml.js",
@@ -3671,6 +3672,9 @@
"src/layers/earthquakes/render/navaraGrid.js", "src/layers/earthquakes/render/navaraGrid.js",
"src/layers/installations/render/navara.js", "src/layers/installations/render/navara.js",
"src/layers/awareness/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/bikeshare/render/navara.js",
"src/layers/installations/render/navaraGeometry.js", "src/layers/installations/render/navaraGeometry.js",
"src/layers/installations/render/navaraOutlineBatch.js", "src/layers/installations/render/navaraOutlineBatch.js",
@@ -4124,6 +4128,7 @@
"src/layers/gnss/records.js", "src/layers/gnss/records.js",
"src/app/layers/ghostMaps.js", "src/app/layers/ghostMaps.js",
"src/layers/ghostMaps/index.js", "src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/render/cesium.js",
"src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js", "src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/kml.js",
@@ -5324,10 +5329,16 @@
"exports": ["./layers/ghostmaps"], "exports": ["./layers/ghostmaps"],
"modules": [ "modules": [
"src/layers/ghostMaps/index.js", "src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/render/cesium.js",
"src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js", "src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/source.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/sources/httpBody.js",
"src/data/geoFileImport.js", "src/data/geoFileImport.js",
"src/i18n/marker.js", "src/i18n/marker.js",
+173
View File
@@ -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();
}
+1 -1
View File
@@ -207,7 +207,7 @@ export function createApplicationCatalog({
], ],
[ [
's2-ghostmaps', 's2-ghostmaps',
() => createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), () => sessionModel('s2-ghostmaps', () => createApplicationGhostMaps({ source: sources['s2-ghostmaps'] })),
], ],
[ [
'alpr-cameras', 'alpr-cameras',
+2
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { rendererFor } from './renderers.js';
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js'; import * as overlays from '../../overlays/worldOverlay.js';
@@ -18,6 +19,7 @@ export function createApplicationGhostMaps(options) {
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking, picking,
pointer: { isPointerFree }, pointer: { isPointerFree },
createRenderer: (engine) => rendererFor('s2-ghostmaps', engine),
...options, ...options,
}); });
} }
+1
View File
@@ -50,6 +50,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'air-quality', 'air-quality',
'fire-perimeters', 'fire-perimeters',
'gnss-integrity', 'gnss-integrity',
's2-ghostmaps',
'alpr-cameras', 'alpr-cameras',
'satellites', 'satellites',
'rocket-launches', 'rocket-launches',
+5
View File
@@ -58,6 +58,7 @@ const NAVARA_CREDIT_KEYS = new Set([
'usgs', 'usgs',
'eccc-aqhi', 'eccc-aqhi',
'wfigs', 'wfigs',
's2-ghostmaps',
'firms', 'firms',
'gbfs', 'gbfs',
'overture-military-names', 'overture-military-names',
@@ -106,6 +107,10 @@ export async function registerNavaraLayerRenderers() {
bikeshare: async () => bikeshare: async () =>
(await import('../layers/bikeshare/render/navara.js')) (await import('../layers/bikeshare/render/navara.js'))
.createBikeshareNavaraRenderer, .createBikeshareNavaraRenderer,
's2-ghostmaps': async () => {
const { createGhostMapsNavaraRenderer } = await import('../layers/ghostMaps/render/navara.js');
return (engine) => createGhostMapsNavaraRenderer(engine, navaraViews.get(engine).view);
},
'military-awareness': async () => 'military-awareness': async () =>
(await import('../layers/awareness/render/navara.js')).createAwarenessNavaraRenderer, (await import('../layers/awareness/render/navara.js')).createAwarenessNavaraRenderer,
'military-installations': async () => { 'military-installations': async () => {
+78 -141
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import { fromDegrees } from '../../geo/cartesian3.js';
import { createGhostMapsCesiumRenderer } from './render/cesium.js';
import { import {
decodeXml, decodeXml,
kmzImageResolver, kmzImageResolver,
@@ -14,7 +15,6 @@ import {
t, t,
tn, tn,
} from '../../i18n/index.js'; } from '../../i18n/index.js';
import { createHorizonCull } from '../streetLevel/view.js';
import { readGhostMapsKml } from './kml.js'; import { readGhostMapsKml } from './kml.js';
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js'; import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
export * from './catalog.js'; export * from './catalog.js';
@@ -106,11 +106,6 @@ function markerImage(color) {
return url; 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) { function anchorOf(geometry) {
if (geometry.type === 'point') return geometry.position; if (geometry.type === 'point') return geometry.position;
const ring = const ring =
@@ -133,6 +128,7 @@ export function createGhostMapsLayer({
source, source,
overlayHost = null, overlayHost = null,
screenSpaceEventHandlerFactory = null, screenSpaceEventHandlerFactory = null,
createRenderer = null,
picking = null, picking = null,
pointer = null, pointer = null,
openExternal = null, openExternal = null,
@@ -140,7 +136,7 @@ export function createGhostMapsLayer({
} = {}) { } = {}) {
if (typeof source?.getCatalog !== 'function') if (typeof source?.getCatalog !== 'function')
throw new TypeError('GhostMaps layer requires a catalog source'); throw new TypeError('GhostMaps layer requires a catalog source');
let _viewer = null; let _display = null;
let _enabled = false; let _enabled = false;
let _catalog = null; let _catalog = null;
let _lastError = null; let _lastError = null;
@@ -154,7 +150,7 @@ export function createGhostMapsLayer({
let _selectedLink = null; let _selectedLink = null;
let _lifetime = new AbortController(); let _lifetime = new AbortController();
let _selection = readStoredSelection(storage); 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(); const _loaded = new Map();
/** productId → in-flight version */ /** productId → in-flight version */
const _loading = new Map(); const _loading = new Map();
@@ -162,18 +158,8 @@ export function createGhostMapsLayer({
const _failed = new Map(); const _failed = new Map();
const notifyRow = () => _rowListener?.(); const notifyRow = () => _rowListener?.();
const render = () => _viewer?.scene?.requestRender?.(); const render = () => _display?.requestRender();
// Pins skip the depth test, and with Google 3D tiles the globe is hidden, const canSelect = () => overlayHost && picking && (screenSpaceEventHandlerFactory || createRenderer);
// 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;
function wanted(product) { function wanted(product) {
return _selection ? _selection.has(product.id) : product.defaultOn; return _selection ? _selection.has(product.id) : product.defaultOn;
@@ -187,12 +173,13 @@ export function createGhostMapsLayer({
const item = _loaded.get(id); const item = _loaded.get(id);
if (!item) return; if (!item) return;
_loaded.delete(id); _loaded.delete(id);
_viewer?.dataSources?.remove(item.dataSource, true); item.removed = true;
item.paint?.destroy();
item.urls.forEach((url) => URL.revokeObjectURL(url)); item.urls.forEach((url) => URL.revokeObjectURL(url));
if (_selectedKey?.startsWith(`${id}:`)) clearSelection(); 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) { async function load(product, signal) {
const bytes = await source.getFile(product.id, product.version, { signal }); const bytes = await source.getFile(product.id, product.version, { signal });
signal.throwIfAborted(); signal.throwIfAborted();
@@ -219,117 +206,41 @@ export function createGhostMapsLayer({
); );
entries.clear(); entries.clear();
signal.throwIfAborted(); signal.throwIfAborted();
const color = PRODUCT_COLORS[product.id] || ACCENT; const features = parsed.features.map((feature) => ({
const dataSource = new Cesium.CustomDataSource( ...feature, anchor: anchorOf(feature.geometries[0]),
`${GHOSTMAPS_LAYER_ID}:${product.id}`, }));
); return { version: product.version, urls, features, paint: null };
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 };
} catch (error) { } catch (error) {
urls.forEach((url) => URL.revokeObjectURL(url)); urls.forEach((url) => URL.revokeObjectURL(url));
throw error; 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. */ /** Bring the globe in line with the catalog and the chip selection. */
function sync() { function sync() {
const run = _queue.then(async () => { const run = _queue.then(async () => {
if (!_enabled || !_viewer || !_catalog) return; if (!_enabled || !_catalog) return;
const signal = _lifetime.signal; const signal = _lifetime.signal;
const products = new Map(_catalog.products.map((p) => [p.id, p])); const products = new Map(_catalog.products.map((p) => [p.id, p]));
for (const id of [..._loaded.keys()]) { for (const id of [..._loaded.keys()]) {
@@ -348,9 +259,17 @@ export function createGhostMapsLayer({
next.urls.forEach((url) => URL.revokeObjectURL(url)); next.urls.forEach((url) => URL.revokeObjectURL(url));
return; return;
} }
// Cull before the first frame so far-side pins never flash. try {
_horizon.update(next.pins); await paint(product.id, next, signal);
await _viewer.dataSources.add(next.dataSource); } 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. // The new edition is on the globe before the old one leaves.
unload(product.id); unload(product.id);
_loaded.set(product.id, next); _loaded.set(product.id, next);
@@ -457,7 +376,7 @@ export function createGhostMapsLayer({
// when its marker turns behind the planet. // when its marker turns behind the planet.
stateless: true, stateless: true,
horizonCull: true, horizonCull: true,
position: Cesium.Cartesian3.fromDegrees(...feature.anchor), position: fromDegrees(...feature.anchor),
}; };
if (card.interactive) { if (card.interactive) {
const link = _selectedLink; const link = _selectedLink;
@@ -496,14 +415,13 @@ export function createGhostMapsLayer({
} }
function installClickHandler() { function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return; if (!canSelect() || _clickHandler || !_display) return;
picking.registerPickOwner?.( picking.registerPickOwner?.(
GHOSTMAPS_LAYER_ID, GHOSTMAPS_LAYER_ID,
(pickedId) => (pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
); );
_clickHandler = screenSpaceEventHandlerFactory(_viewer); _clickHandler = _display.onClick((click, pick) => {
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return; if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.( const cardHit = overlayHost.hitTest?.(
click.position?.x, click.position?.x,
@@ -514,7 +432,7 @@ export function createGhostMapsLayer({
if (_selectedLink && openExternal) openExternal(_selectedLink); if (_selectedLink && openExternal) openExternal(_selectedLink);
return; return;
} }
const picked = _viewer.scene.pick(click.position); const picked = pick();
const key = picked ? pickedKey(picked) : null; const key = picked ? pickedKey(picked) : null;
if (key) { if (key) {
_selectedKey = key; _selectedKey = key;
@@ -531,11 +449,11 @@ export function createGhostMapsLayer({
clearSelection(); clearSelection();
render(); render();
} }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); });
} }
function removeClickHandler() { function removeClickHandler() {
_clickHandler?.destroy(); _clickHandler?.();
_clickHandler = null; _clickHandler = null;
picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID); picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID);
} }
@@ -555,15 +473,35 @@ export function createGhostMapsLayer({
// the backstop if the stream is down. // the backstop if the stream is down.
updateInterval: 300000, updateInterval: 300000,
init(viewer) { init(viewer, options = {}) {
if (_viewer) throw new Error('GhostMaps layer is already initialized'); if (viewer || options.engine) return this.attachRenderer(viewer, options);
_viewer = viewer; },
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() { enable() {
_enabled = true; _enabled = true;
_lifetime = new AbortController(); _lifetime = new AbortController();
for (const item of _loaded.values()) item.dataSource.show = true;
installClickHandler(); installClickHandler();
if (!_unsubscribe && typeof source.subscribe === 'function') if (!_unsubscribe && typeof source.subscribe === 'function')
_unsubscribe = source.subscribe( _unsubscribe = source.subscribe(
@@ -586,7 +524,6 @@ export function createGhostMapsLayer({
_stream = 'closed'; _stream = 'closed';
removeClickHandler(); removeClickHandler();
clearSelection(); clearSelection();
_horizon.stop();
for (const id of [..._loaded.keys()]) unload(id); for (const id of [..._loaded.keys()]) unload(id);
render(); render();
}, },
@@ -610,7 +547,7 @@ export function createGhostMapsLayer({
destroy() { destroy() {
layer.disable(); layer.disable();
_viewer = null; layer.detachRenderer();
_catalog = null; _catalog = null;
_rowListener = null; _rowListener = null;
}, },
+16
View File
@@ -155,6 +155,22 @@ test('cancellation during parsing releases every packed-image URL', async (t) =>
assert.equal(mounted.size, 0); 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) => { test('entity construction failure releases parsed image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t); const { layer, viewer, mounted, created, revoked } = fixture(t);
t.mock.method(Cesium.EntityCollection.prototype, 'add', () => { t.mock.method(Cesium.EntityCollection.prototype, 'add', () => {
+142
View File
@@ -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,
};
}
+138
View File
@@ -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() {},
};
}
+122
View File
@@ -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);
}
+3 -19
View File
@@ -2,23 +2,7 @@ import * as geo from '../../../geo/index.js';
import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js'; import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js';
import { containmentAccent } from '../cards.js'; import { containmentAccent } from '../cards.js';
// A fire ring is local, including across 180°. Put the click and ring in import { containsGroundPoint } from '../../shared/render/groundRing.js';
// 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;
}
export function createPerimetersNavaraRenderer(engine, { view }) { export function createPerimetersNavaraRenderer(engine, { view }) {
let polygons = []; let polygons = [];
@@ -86,8 +70,8 @@ export function createPerimetersNavaraRenderer(engine, { view }) {
for (let i = polygons.length - 1; i >= 0; i--) { for (let i = polygons.length - 1; i >= 0; i--) {
const { id, rings } = polygons[i]; const { id, rings } = polygons[i];
if ( if (
contains(rings[0], lon, lat) && containsGroundPoint(rings[0], lon, lat) &&
!rings.slice(1).some((ring) => contains(ring, lon, lat)) !rings.slice(1).some((ring) => containsGroundPoint(ring, lon, lat))
) )
return { id }; return { id };
} }
+17
View File
@@ -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;
}