mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Keep GhostMaps KMZ editions and selections across native globe renderers
This commit is contained in:
1 parent
87bbe14748
commit
11992828fb
14 files changed
+709
-161
No files matched your search
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -207,7 +207,7 @@ export function createApplicationCatalog({
|
||||
],
|
||||
[
|
||||
's2-ghostmaps',
|
||||
() => createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||
() => sessionModel('s2-ghostmaps', () => createApplicationGhostMaps({ source: sources['s2-ghostmaps'] })),
|
||||
],
|
||||
[
|
||||
'alpr-cameras',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+78
-141
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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() {},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user