mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Draw local infrastructure on either globe from one retained dataset
This commit is contained in:
1 parent
868d061856
commit
c1d829ef51
12 files changed
+900
-6
No files matched your search
@@ -2715,6 +2715,7 @@
|
||||
"src/data/groundSnap.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/infrastructure.js",
|
||||
"src/layers/infrastructure/index.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/idSwitches.js",
|
||||
@@ -3672,6 +3673,8 @@
|
||||
"src/layers/earthquakes/render/navaraGrid.js",
|
||||
"src/layers/installations/render/navara.js",
|
||||
"src/layers/awareness/render/navara.js",
|
||||
"src/layers/infrastructure/render/navara.js",
|
||||
"src/layers/infrastructure/render/navaraStems.js",
|
||||
"src/layers/weather/render/navara.js",
|
||||
"src/layers/weather/render/navaraSurface.js",
|
||||
"src/layers/ghostMaps/render/navara.js",
|
||||
@@ -3811,6 +3814,7 @@
|
||||
"src/data/geoid.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/infrastructure.js",
|
||||
"src/layers/infrastructure/index.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import puppeteer from 'puppeteer';
|
||||
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=41.5340&lon=-90.5070&alt=20000&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 reads = { datacenters: 0, dams: 0 };
|
||||
p.on('request', (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
for (const name of Object.keys(reads))
|
||||
if (path.includes(name) && path.endsWith('.geojsonl')) reads[name]++;
|
||||
});
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
window.qaSelections = [];
|
||||
window.addEventListener('gev:entity-selected', ({ detail }) => {
|
||||
if (detail?.layerId?.startsWith('local-'))
|
||||
window.qaSelections.push(detail.id);
|
||||
});
|
||||
});
|
||||
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');
|
||||
const ids = ['local-datacenters', 'local-dams'];
|
||||
const dataset = await p.evaluate(async (ids) => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
window.qaSites = [];
|
||||
const records = [];
|
||||
for (const id of ids) {
|
||||
if (!(await dataManager.setEnabled(id, true, { origin: 'user' })))
|
||||
throw Error(id + ' did not enable');
|
||||
const mod = dataManager.layers.get(id).module;
|
||||
window.qaSites.push(mod);
|
||||
const all = mod.getAnalystRecords(Infinity);
|
||||
records.push({
|
||||
count: mod.getStats().count,
|
||||
total: all.length,
|
||||
name: all[0]?.name,
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}, ids);
|
||||
assert.ok(
|
||||
dataset[0].count >= 4351 && dataset[1].count >= 704,
|
||||
JSON.stringify(dataset),
|
||||
);
|
||||
const record = await p.evaluate(() =>
|
||||
window.qaSites[0]
|
||||
.getAnalystRecords(5000)
|
||||
.find((r) => r.name === 'Geneseo Communications Inc'),
|
||||
);
|
||||
assert.ok(record);
|
||||
const clickSite = async () => {
|
||||
const screen = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const at = engine.projector.worldToWindow(world, {});
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
return { x: box.left + at.x, y: box.top + at.y };
|
||||
},
|
||||
fromDegrees(record.lon, record.lat),
|
||||
);
|
||||
await p.mouse.click(screen.x, screen.y);
|
||||
await p.waitForFunction(
|
||||
() => window.qaSelections.at(-1) === 'local-datacenters:342664739',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
};
|
||||
const counts = { ...reads };
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await p.evaluate(() => {
|
||||
window.qaSelections = [];
|
||||
});
|
||||
await p.select('#globe-engine', kind);
|
||||
await ready(kind);
|
||||
await p.waitForFunction(
|
||||
() => window.qaSelections.includes('local-datacenters:342664739'),
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const result = await p.evaluate(
|
||||
(ids) =>
|
||||
ids.map((id, i) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get(id).module;
|
||||
return {
|
||||
same: mod === window.qaSites[i],
|
||||
lod: mod.getLodDiagnostics(),
|
||||
count: mod.getStats().count,
|
||||
error: mod.getStats().error,
|
||||
name: mod.getAnalystRecords(1)[0].name,
|
||||
};
|
||||
}),
|
||||
ids,
|
||||
);
|
||||
console.log(kind, JSON.stringify(result));
|
||||
for (const [i, layer] of result.entries()) {
|
||||
assert.ok(
|
||||
layer.same &&
|
||||
!layer.error &&
|
||||
layer.lod.active > 0 &&
|
||||
layer.lod.active <= layer.lod.budgetLimit,
|
||||
);
|
||||
assert.equal(layer.count, dataset[i].count);
|
||||
assert.equal(layer.name, dataset[i].name);
|
||||
}
|
||||
if (
|
||||
kind === 'navara' &&
|
||||
!(await p.evaluate(() => window.qaSelections.length))
|
||||
)
|
||||
await clickSite();
|
||||
assert.deepEqual(
|
||||
reads,
|
||||
counts,
|
||||
'Do not reload bundled data on scene handoff',
|
||||
);
|
||||
}
|
||||
assert.equal(counts.datacenters, 1);
|
||||
assert.equal(counts.dams, 1);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS complete bundled datasets, bounded stems, retained selection and one read per file',
|
||||
);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||
@@ -302,7 +303,11 @@ export function createApplicationCatalog({
|
||||
],
|
||||
[
|
||||
['local-datacenters', 'local-dams'],
|
||||
() => createInfrastructureLayers(localGeoJsonServices),
|
||||
() => sessionModel('local-infrastructure', () =>
|
||||
createInfrastructureLayers(localGeoJsonServices, (config, services) =>
|
||||
createInfrastructureSessionLayer(config, services, (engine, descriptor, ops) =>
|
||||
rendererFor(config.id,engine,{config:descriptor,services:ops}))),
|
||||
),
|
||||
],
|
||||
[
|
||||
'telegeography-submarine-cables',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
getSelectedEntityContext,
|
||||
} from '../data/contextStore.js';
|
||||
import {
|
||||
clearOverlaySource,
|
||||
@@ -22,6 +23,7 @@ export const localGeoJsonServices = Object.freeze({
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
getSelectedEntityContext,
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
|
||||
@@ -66,6 +66,8 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'local-firms',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -62,6 +62,7 @@ const NAVARA_CREDIT_KEYS = new Set([
|
||||
'weather-noaa',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
'dams',
|
||||
'firms',
|
||||
'gbfs',
|
||||
'overture-military-names',
|
||||
@@ -120,6 +121,12 @@ export async function registerNavaraLayerRenderers() {
|
||||
const {createCyclonesNavaraRenderer} = await import('../layers/cyclones/render/navara.js');
|
||||
return (engine,options) => createCyclonesNavaraRenderer(engine,navaraViews.get(engine).view,options);
|
||||
},
|
||||
...Object.fromEntries(['local-datacenters','local-dams'].map((id) => [
|
||||
id, async () => {
|
||||
const {createInfrastructureNavaraRenderer} = await import('../layers/infrastructure/render/navara.js');
|
||||
return (engine,{config,services}) => createInfrastructureNavaraRenderer(engine,navaraViews.get(engine),config,services);
|
||||
},
|
||||
])),
|
||||
's2-ghostmaps': async () => {
|
||||
const { createGhostMapsNavaraRenderer } = await import('../layers/ghostMaps/render/navara.js');
|
||||
return (engine) => createGhostMapsNavaraRenderer(engine, navaraViews.get(engine).view);
|
||||
|
||||
@@ -6,10 +6,10 @@ import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
|
||||
* @param {object} services Caller-owned context, overlay and render operations.
|
||||
* @returns {object[]} Datacenters then dams, with stable standalone identities.
|
||||
*/
|
||||
export function createInfrastructureLayers(services) {
|
||||
export function createInfrastructureLayers(services, createLayer = createLocalGeoJsonLayer) {
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
const datacenters = createLocalGeoJsonLayer(
|
||||
const datacenters = createLayer(
|
||||
{
|
||||
id: 'local-datacenters',
|
||||
url: datacentersUrl,
|
||||
@@ -25,7 +25,7 @@ export function createInfrastructureLayers(services) {
|
||||
services,
|
||||
);
|
||||
|
||||
const dams = createLocalGeoJsonLayer(
|
||||
const dams = createLayer(
|
||||
{
|
||||
id: 'local-dams',
|
||||
url: damsUrl,
|
||||
|
||||
@@ -307,6 +307,7 @@ export function createLocalGeoJsonLayer(
|
||||
icon = '📍',
|
||||
source = 'Local JSONL',
|
||||
osmDerived = false,
|
||||
readFeatures = null,
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
@@ -584,7 +585,8 @@ export function createLocalGeoJsonLayer(
|
||||
// windows (before vs after the add settles) need different cleanup.
|
||||
let addedToScene = false;
|
||||
try {
|
||||
let features = _cachedFeatures;
|
||||
let features = _cachedFeatures ?? (readFeatures ? await readFeatures() : null);
|
||||
if (_destroyed) return;
|
||||
if (!features) {
|
||||
const response = await fetch(url, {
|
||||
signal: _loadController.signal,
|
||||
@@ -1062,6 +1064,14 @@ export function createLocalGeoJsonLayer(
|
||||
viewer.scene.requestRender?.();
|
||||
},
|
||||
|
||||
restoreFeatureSelection(viewer, featureId) {
|
||||
const entity = _dataSource?.entities.values.find((item) => String(item.id) === featureId);
|
||||
if (!entity) return false;
|
||||
viewer.selectedEntity = entity;
|
||||
selectEntityContext(entity);
|
||||
return true;
|
||||
},
|
||||
|
||||
disable: disableLayer,
|
||||
|
||||
destroy: (viewer) => {
|
||||
@@ -1234,7 +1244,7 @@ function featureLabelFromProperties(props, layerId) {
|
||||
return clampLabel(text || layerTitle(layerId));
|
||||
}
|
||||
|
||||
function labelPriorityFromProperties(props, layerId) {
|
||||
export function labelPriorityFromProperties(props, layerId) {
|
||||
const tags = props.tags || {};
|
||||
|
||||
let score = 0;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as geo from '../../geo/index.js';
|
||||
import {
|
||||
mapAnalystRecord,
|
||||
parseGeojsonLines,
|
||||
} from '../../sources/infrastructureData.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer,
|
||||
localDatasetError,
|
||||
} from '../../data/localGeojsonCore.js';
|
||||
|
||||
// Split multipart features exactly as the Cesium entity collection does, with stable IDs on both globes.
|
||||
function dataset(raw) {
|
||||
const features = [],
|
||||
records = [],
|
||||
used = new Set();
|
||||
function part(feature, geometry, key) {
|
||||
if (!geometry) return;
|
||||
if (geometry.type === 'GeometryCollection') {
|
||||
for (const g of geometry.geometries) part(feature, g, key);
|
||||
return;
|
||||
}
|
||||
if (geometry.type.startsWith('Multi')) {
|
||||
for (const coordinates of geometry.coordinates)
|
||||
part(feature, { type: geometry.type.slice(5), coordinates }, key);
|
||||
return;
|
||||
}
|
||||
let id = key,
|
||||
suffix = 2;
|
||||
while (used.has(id)) id = key + '_' + suffix++;
|
||||
used.add(id);
|
||||
features.push({ ...feature, id, geometry });
|
||||
let base;
|
||||
if (geometry.type === 'Point')
|
||||
base = geo.cartesian3.fromDegrees(...geometry.coordinates);
|
||||
if (geometry.type === 'Polygon') {
|
||||
const ring = geometry.coordinates[0];
|
||||
if (ring.length)
|
||||
base = geo.boundingSphere.fromPoints(
|
||||
ring.map(([lon, lat, h]) => geo.cartesian3.fromDegrees(lon, lat, h)),
|
||||
).center;
|
||||
}
|
||||
if (!base) return;
|
||||
const pos = geo.cartographic.fromCartesian(base);
|
||||
if (pos)
|
||||
records.push({
|
||||
id,
|
||||
lon: geo.math.toDegrees(pos.longitude),
|
||||
lat: geo.math.toDegrees(pos.latitude),
|
||||
properties: feature.properties ?? {},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
raw.forEach((f, index) =>
|
||||
part(
|
||||
f,
|
||||
f.geometry,
|
||||
String(f.id ?? f.properties?.osm_id ?? 'feature-' + index),
|
||||
),
|
||||
);
|
||||
return { features, records };
|
||||
}
|
||||
|
||||
/** The parsed local file and the selected feature outlive any one scene. */
|
||||
export function createInfrastructureSessionLayer(
|
||||
config,
|
||||
services,
|
||||
nativeFactory,
|
||||
) {
|
||||
const { id, name, icon, source, url } = config;
|
||||
let data = null,
|
||||
request = null,
|
||||
abort = null,
|
||||
display = null,
|
||||
enabled = false,
|
||||
destroyed = false;
|
||||
let updated = null,
|
||||
error = null,
|
||||
selectedId = null;
|
||||
const read = () => {
|
||||
if (data) return Promise.resolve(data);
|
||||
if (!request) {
|
||||
const controller = new AbortController();
|
||||
abort = controller;
|
||||
request = (async () => {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) throw Error('HTTP ' + res.status);
|
||||
const raw = parseGeojsonLines(await res.text());
|
||||
controller.signal.throwIfAborted();
|
||||
data = dataset(raw);
|
||||
updated = Date.now();
|
||||
error = null;
|
||||
return data;
|
||||
})().finally(() => {
|
||||
if (controller === abort) {
|
||||
request = null;
|
||||
abort = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
return request;
|
||||
};
|
||||
const show = async () => {
|
||||
try {
|
||||
const next = await read();
|
||||
if (!enabled || destroyed) return;
|
||||
const active = display;
|
||||
if (active) {
|
||||
error = null;
|
||||
await active.show(next);
|
||||
}
|
||||
if (
|
||||
active &&
|
||||
active === display &&
|
||||
enabled &&
|
||||
selectedId &&
|
||||
!services.getSelectedEntityContext?.()
|
||||
)
|
||||
active.select(selectedId);
|
||||
} catch (cause) {
|
||||
if (!destroyed && cause?.name !== 'AbortError')
|
||||
error = localDatasetError(cause);
|
||||
}
|
||||
};
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
source,
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
init(viewer, options = {}) {
|
||||
if (viewer || options.engine) return this.attachRenderer(viewer, options);
|
||||
},
|
||||
attachRenderer(viewer, { engine = null } = {}) {
|
||||
if (display) throw Error('Infrastructure display already attached');
|
||||
if (viewer) {
|
||||
const legacy = createLocalGeoJsonLayer(
|
||||
{ ...config, readFeatures: async () => (await read()).features },
|
||||
services,
|
||||
);
|
||||
display = {
|
||||
show: async () => {
|
||||
await legacy.enable(viewer);
|
||||
error = legacy.getStats().error;
|
||||
},
|
||||
select: (key) => legacy.restoreFeatureSelection(viewer, key),
|
||||
hide: () => legacy.disable(viewer),
|
||||
destroy: () => legacy.destroy(viewer),
|
||||
lod: legacy.getLodDiagnostics,
|
||||
};
|
||||
} else display = nativeFactory(engine, config, services);
|
||||
if (enabled) return show();
|
||||
},
|
||||
detachRenderer() {
|
||||
if (!display) return;
|
||||
const selected = services.getSelectedEntityContext?.();
|
||||
selectedId =
|
||||
selected?.layerId === id
|
||||
? String(selected.id).slice(id.length + 1)
|
||||
: null;
|
||||
display.destroy();
|
||||
display = null;
|
||||
},
|
||||
enable(viewer) {
|
||||
if (!display && viewer) this.attachRenderer(viewer);
|
||||
enabled = true;
|
||||
return show();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
selectedId = null;
|
||||
display?.hide();
|
||||
},
|
||||
update() {},
|
||||
getStats: () => ({
|
||||
count: data?.features.length ?? 0,
|
||||
lastUpdate: updated,
|
||||
error,
|
||||
}),
|
||||
getLodDiagnostics: () =>
|
||||
display?.lod() ?? {
|
||||
total: 0,
|
||||
active: 0,
|
||||
budgetLimit: 0,
|
||||
computed: false,
|
||||
},
|
||||
getAnalystRecords(limit = 2000) {
|
||||
if (!enabled || !data) return [];
|
||||
return data.records
|
||||
.slice(
|
||||
0,
|
||||
Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 2000,
|
||||
)
|
||||
.map((r) => mapAnalystRecord(r, id));
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
enabled = false;
|
||||
abort?.abort();
|
||||
display?.destroy();
|
||||
display = data = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import {
|
||||
selectInfraLod,
|
||||
applyInfraEvictionGrace,
|
||||
INFRA_LOD_ACTIVE_MAX,
|
||||
} from '../../../data/localGeojsonLod.js';
|
||||
import {
|
||||
createLocalInfrastructureOverlayEntry,
|
||||
selectLocalInfrastructureOverlayCohort,
|
||||
createLocalInfrastructureOverlayPublisher,
|
||||
LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
labelPriorityFromProperties,
|
||||
localInfrastructureOverlayCopy,
|
||||
} from '../../../data/localGeojsonCore.js';
|
||||
import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js';
|
||||
import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js';
|
||||
import { distanceToSegment } from '../../../globe/cpuPicks.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import { createStemBatch } from './navaraStems.js';
|
||||
|
||||
export function createInfrastructureNavaraRenderer(
|
||||
engine,
|
||||
native,
|
||||
config,
|
||||
services,
|
||||
) {
|
||||
const { id, name, source, color, labelMax, labelGridPx } = config;
|
||||
const rgba = rgbaFromCss(color).rgba();
|
||||
const publisher = createLocalInfrastructureOverlayPublisher({
|
||||
sourceId: id,
|
||||
host: services.overlayHost,
|
||||
});
|
||||
const display = createNavaraPolygonDisplay(
|
||||
engine,
|
||||
native.view,
|
||||
(tint, show) => ({
|
||||
polyline: { color: tint, show, width: 2, clampToGround: true },
|
||||
polygon: {
|
||||
color: tint,
|
||||
show,
|
||||
opacity: 0.3,
|
||||
transparent: true,
|
||||
clampToGround: true,
|
||||
outline: true,
|
||||
outlineColor: tint,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let dataset = null,
|
||||
records = new Map(),
|
||||
active = new Set(),
|
||||
grace = new Map(),
|
||||
shown = [],
|
||||
budget = 0;
|
||||
let lastWalk = -Infinity,
|
||||
signature = '',
|
||||
topology = '',
|
||||
points = null,
|
||||
stems = null,
|
||||
unbindShapes = null;
|
||||
let retryTimer = null;
|
||||
const removers = [];
|
||||
const wake = () => engine.render.request(id);
|
||||
const key = (fid) => id + ':' + fid;
|
||||
function footprints() {
|
||||
const features = [],
|
||||
shapes = [];
|
||||
for (const feature of dataset.features) {
|
||||
const g = feature.geometry;
|
||||
if (
|
||||
g.type !== 'LineString' &&
|
||||
(g.type !== 'Polygon' || !active.has(feature.id))
|
||||
)
|
||||
continue;
|
||||
features.push(feature);
|
||||
shapes.push({
|
||||
id: key(feature.id),
|
||||
geometry:
|
||||
g.type === 'LineString'
|
||||
? { type: 'line', positions: g.coordinates }
|
||||
: {
|
||||
type: 'polygon',
|
||||
outer: g.coordinates[0],
|
||||
holes: g.coordinates.slice(1),
|
||||
},
|
||||
style: { lineWidth: 2 },
|
||||
});
|
||||
}
|
||||
display.replace(features.length ? new Map([[color, features]]) : new Map());
|
||||
unbindShapes?.();
|
||||
unbindShapes = bindGroundShapePicks(engine, id, shapes);
|
||||
}
|
||||
function sweep(force = false) {
|
||||
if (!dataset) return;
|
||||
const now = performance.now();
|
||||
if (!force && now - lastWalk < 450) return;
|
||||
lastWalk = now;
|
||||
const pose = engine.camera.pose(),
|
||||
size = engine.host.size();
|
||||
const sig = [
|
||||
pose.position.x,
|
||||
pose.position.y,
|
||||
pose.position.z,
|
||||
pose.heading,
|
||||
pose.pitch,
|
||||
pose.roll,
|
||||
pose.fovy,
|
||||
size.width,
|
||||
size.height,
|
||||
].join('/');
|
||||
const sampling = shown.some(
|
||||
(r) =>
|
||||
!r.grounded &&
|
||||
r.tries < 30 &&
|
||||
now - r.lastSample >= 2000 &&
|
||||
r.distance < 75000,
|
||||
);
|
||||
if (!force && signature === sig && !sampling) return;
|
||||
if (force || signature !== sig) {
|
||||
const candidates = [];
|
||||
for (const r of records.values()) {
|
||||
r.distance = geo.cartesian3.distance(pose.position, r.base);
|
||||
candidates.push({
|
||||
id: r.id,
|
||||
priority: r.priority,
|
||||
distanceM: r.distance,
|
||||
inView: engine.projector.horizonVisible(r.base),
|
||||
});
|
||||
}
|
||||
const result = selectInfraLod(candidates, {
|
||||
cameraHeightM: pose.cartographic.height,
|
||||
incumbentIds: active,
|
||||
});
|
||||
budget = result.budget.activeLimit;
|
||||
const next = applyInfraEvictionGrace({
|
||||
selectedIds: result.activeIds,
|
||||
builtIds: [...active],
|
||||
graceState: grace,
|
||||
nowMs: now,
|
||||
activeLimit: budget,
|
||||
});
|
||||
active = new Set(next.keepIds);
|
||||
grace = next.graceState;
|
||||
signature = sig;
|
||||
const nextTopology = next.keepIds.slice().sort().join('/');
|
||||
if (force || topology !== nextTopology) {
|
||||
topology = nextTopology;
|
||||
footprints();
|
||||
}
|
||||
}
|
||||
shown = [];
|
||||
for (const fid of active) {
|
||||
const r = records.get(fid);
|
||||
if (!engine.projector.horizonVisible(r.base)) continue;
|
||||
const carto = r.carto;
|
||||
if (
|
||||
r.distance < 75000 &&
|
||||
now - r.lastSample >= 2000 &&
|
||||
(force || r.tries < 30)
|
||||
) {
|
||||
const terrain = engine.surface.terrainHeight(carto);
|
||||
const sampled = engine.surface.supported
|
||||
? engine.surface.sampleHeight(carto, [points])
|
||||
: undefined;
|
||||
const floor = Number.isFinite(sampled)
|
||||
? Math.max(sampled, terrain ?? -Infinity)
|
||||
: terrain;
|
||||
r.lastSample = now;
|
||||
r.tries++;
|
||||
if (Number.isFinite(floor) && Math.abs(floor) <= 9000) {
|
||||
r.floor = floor;
|
||||
r.grounded = Number.isFinite(sampled) || !engine.surface.supported;
|
||||
geo.cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
floor,
|
||||
undefined,
|
||||
r.base,
|
||||
);
|
||||
}
|
||||
}
|
||||
const height =
|
||||
r.floor +
|
||||
(Math.max(1, r.distance) * 2 * Math.tan(pose.fovy / 2) * 65) /
|
||||
Math.max(1, size.height);
|
||||
geo.cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
height,
|
||||
undefined,
|
||||
r.tip,
|
||||
);
|
||||
points.set(shown.length, {
|
||||
position: r.tip,
|
||||
color: rgba,
|
||||
show: true,
|
||||
pickId: key(fid),
|
||||
});
|
||||
shown.push(r);
|
||||
}
|
||||
points.setCount(shown.length);
|
||||
points.commit();
|
||||
stems.set(shown, pose.position);
|
||||
publisher.publish(
|
||||
selectLocalInfrastructureOverlayCohort(shown, {
|
||||
maxEntries: labelMax,
|
||||
gridPx: labelGridPx,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
project: (r) => engine.projector.worldToWindow(r.tip, {}),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
!retryTimer &&
|
||||
engine.surface.supported &&
|
||||
shown.some((r) => !r.grounded && r.distance < 75000 && r.tries < 30)
|
||||
) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
wake();
|
||||
}, 2050);
|
||||
}
|
||||
wake();
|
||||
}
|
||||
function select(fid) {
|
||||
const row = records.get(fid);
|
||||
if (!row) return false;
|
||||
services.selectEntityContext(row);
|
||||
return true;
|
||||
}
|
||||
function hide() {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
while (removers.length) removers.pop()();
|
||||
unbindShapes?.();
|
||||
unbindShapes = null;
|
||||
publisher.hide();
|
||||
display.destroy();
|
||||
points?.destroy();
|
||||
stems?.destroy();
|
||||
points = stems = dataset = null;
|
||||
records.clear();
|
||||
active.clear();
|
||||
grace.clear();
|
||||
shown = [];
|
||||
budget = 0;
|
||||
signature = topology = '';
|
||||
services.removeEntityContextsForLayer(id);
|
||||
services.hideOsmCredit(engine, id);
|
||||
}
|
||||
return {
|
||||
show(next) {
|
||||
if (dataset === next) return;
|
||||
dataset = next;
|
||||
points = engine.movers.createSet({
|
||||
id,
|
||||
kind: 'point',
|
||||
capacity: INFRA_LOD_ACTIVE_MAX,
|
||||
pickable: true,
|
||||
depthTest: 'always',
|
||||
point: { sizePx: 10, outlinePx: 2, outlineColor: [0, 0, 0, 1] },
|
||||
});
|
||||
stems = createStemBatch(native, color);
|
||||
for (const input of next.records) {
|
||||
const carto = geo.cartographic.fromDegrees(input.lon, input.lat);
|
||||
const base = geo.cartesian3.fromDegrees(input.lon, input.lat),
|
||||
tip = geo.cartesian3.clone(base);
|
||||
const priority = labelPriorityFromProperties(input.properties, id);
|
||||
const row = {
|
||||
...input,
|
||||
carto,
|
||||
base,
|
||||
tip,
|
||||
priority,
|
||||
floor: 0,
|
||||
tries: 0,
|
||||
lastSample: -Infinity,
|
||||
grounded: false,
|
||||
};
|
||||
row.entry = createLocalInfrastructureOverlayEntry({
|
||||
id: row.id,
|
||||
layerId: id,
|
||||
position: tip,
|
||||
properties: row.properties,
|
||||
priority,
|
||||
accent: color,
|
||||
});
|
||||
records.set(row.id, row);
|
||||
services.registerEntityContext(row, {
|
||||
id: key(row.id),
|
||||
layerId: id,
|
||||
layerName: name,
|
||||
source,
|
||||
label: localInfrastructureOverlayCopy(row.properties, id).title,
|
||||
properties: row.properties,
|
||||
latitude: row.lat,
|
||||
longitude: row.lon,
|
||||
});
|
||||
}
|
||||
display.setVisible(true);
|
||||
publisher.show();
|
||||
if (next.features.length) services.showOsmCredit(engine, id);
|
||||
removers.push(
|
||||
engine.frames.preRender.addEventListener(() => sweep()),
|
||||
engine.camera.moveEnd.addEventListener(() => sweep(true)),
|
||||
);
|
||||
removers.push(
|
||||
engine.picker.registerMeshPicker((at) => {
|
||||
const hits = [];
|
||||
for (const row of shown) {
|
||||
const a = engine.projector.worldToWindow(row.base, {}),
|
||||
b = engine.projector.worldToWindow(row.tip, {});
|
||||
if (a && b && distanceToSegment(at, a, b) <= 2.5)
|
||||
hits.push({
|
||||
onTop: 0,
|
||||
order: 0,
|
||||
depth: row.distance,
|
||||
result: { id: key(row.id), owner: id },
|
||||
});
|
||||
}
|
||||
return hits;
|
||||
}),
|
||||
);
|
||||
removers.push(
|
||||
engine.input.on(
|
||||
'click',
|
||||
(event) => {
|
||||
if (!isPointerFree() || event.picked?.owner !== id) return;
|
||||
const fid = String(event.picked.id).slice(id.length + 1);
|
||||
const row = records.get(fid);
|
||||
const feature = row || next.features.find((f) => f.id === fid);
|
||||
if (!feature) return;
|
||||
if (row) select(fid);
|
||||
const [lon, lat] = row
|
||||
? [row.lon, row.lat]
|
||||
: feature.geometry.coordinates[0];
|
||||
engine.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, 5000),
|
||||
duration: 1.5,
|
||||
});
|
||||
},
|
||||
{ pick: true },
|
||||
),
|
||||
);
|
||||
sweep(true);
|
||||
},
|
||||
select,
|
||||
hide,
|
||||
destroy() {
|
||||
hide();
|
||||
publisher.destroy();
|
||||
},
|
||||
lod: () => ({
|
||||
total: records.size,
|
||||
active: active.size,
|
||||
budgetLimit: budget,
|
||||
computed: Boolean(dataset),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
|
||||
import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
|
||||
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
||||
import { INFRA_LOD_ACTIVE_MAX } from '../../../data/localGeojsonLod.js';
|
||||
|
||||
const KEY = 'gevInfrastructureStems';
|
||||
const views = new WeakSet();
|
||||
/** All visible stems use one draw and one fixed buffer; tips are a point bank. */
|
||||
export function createStemBatch({ view, MeshDesc }, color) {
|
||||
if (!views.has(view)) {
|
||||
view.registerMesh(
|
||||
KEY,
|
||||
class extends MeshDesc {
|
||||
constructor(host, ctx, config) {
|
||||
super(host, ctx, config);
|
||||
this.batch = config[KEY];
|
||||
}
|
||||
getPassKey() {
|
||||
return 'transparent';
|
||||
}
|
||||
createMesh() {
|
||||
return this.batch.line;
|
||||
}
|
||||
onDestroy() {
|
||||
this.batch.dispose();
|
||||
super.onDestroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
views.add(view);
|
||||
}
|
||||
const xyz = new Float32Array(6 * INFRA_LOD_ACTIVE_MAX);
|
||||
const geometry = new LineSegmentsGeometry().setPositions(xyz);
|
||||
geometry.instanceCount = 0;
|
||||
const material = new LineMaterial({
|
||||
color,
|
||||
linewidth: 3.5,
|
||||
depthWrite: false,
|
||||
transparent: true,
|
||||
toneMapped: false,
|
||||
});
|
||||
const line = new LineSegments2(geometry, material);
|
||||
line.frustumCulled = false;
|
||||
const holder = view.addMesh({
|
||||
[KEY]: {
|
||||
line,
|
||||
dispose() {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
set(records, origin) {
|
||||
let at = 0;
|
||||
for (const { base, tip } of records)
|
||||
for (const p of [base, tip]) {
|
||||
xyz[at++] = p.x - origin.x;
|
||||
xyz[at++] = p.y - origin.y;
|
||||
xyz[at++] = p.z - origin.z;
|
||||
}
|
||||
line.position.set(origin.x, origin.y, origin.z);
|
||||
line.updateMatrix();
|
||||
line.matrixWorldNeedsUpdate = true;
|
||||
geometry.instanceCount = records.length;
|
||||
geometry.attributes.instanceStart.data.needsUpdate = true;
|
||||
},
|
||||
destroy: () => holder.delete(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInfrastructureSessionLayer } from './index.js';
|
||||
|
||||
test('a bundled file delivered between planets keeps multipart IDs and is read only once', async (t) => {
|
||||
let deliver,
|
||||
reads = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
reads++;
|
||||
return {
|
||||
ok: true,
|
||||
text: () =>
|
||||
new Promise((resolve) => {
|
||||
deliver = resolve;
|
||||
}),
|
||||
};
|
||||
});
|
||||
const captures = [];
|
||||
const layer = createInfrastructureSessionLayer(
|
||||
{ id: 'local-dams', name: 'Dams', url: '/dams' },
|
||||
{},
|
||||
() => ({
|
||||
show: (data) => captures.push(data),
|
||||
hide() {},
|
||||
destroy() {},
|
||||
lod() {},
|
||||
}),
|
||||
);
|
||||
layer.init(null, { engine: {} });
|
||||
const enabled = layer.enable();
|
||||
await new Promise(setImmediate);
|
||||
layer.detachRenderer();
|
||||
const ring = [
|
||||
[10, 20],
|
||||
[10.02, 20],
|
||||
[10, 20.01],
|
||||
[10, 20],
|
||||
];
|
||||
deliver(
|
||||
JSON.stringify({
|
||||
type: 'Feature',
|
||||
id: 'd',
|
||||
properties: { name: 'Dam' },
|
||||
geometry: {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[ring], [ring]],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await enabled;
|
||||
assert.equal(layer.getAnalystRecords().length, 2);
|
||||
assert.equal(captures.length, 0);
|
||||
await layer.attachRenderer(null, { engine: {} });
|
||||
layer.detachRenderer();
|
||||
await layer.attachRenderer(null, { engine: {} });
|
||||
assert.equal(captures[0], captures[1]);
|
||||
assert.deepEqual(
|
||||
captures[0].records.map((r) => r.id),
|
||||
['d', 'd_2'],
|
||||
);
|
||||
assert.equal(reads, 1);
|
||||
layer.destroy();
|
||||
});
|
||||
Reference in new issue
Block a user