Draw local infrastructure on either globe from one retained dataset

This commit is contained in:
German Khokhlov committed 2026-11-03 03:28:26 +04:00
1 parent 868d061856
commit c1d829ef51
12 files changed
+900 -6

No files matched your search

+4
View File
@@ -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",
+164
View File
@@ -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();
}
+6 -1
View File
@@ -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',
+2
View File
@@ -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,
+2
View File
@@ -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',
],
);
+7
View File
@@ -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);
+3 -3
View File
@@ -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,
+12 -2
View File
@@ -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;
+204
View File
@@ -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;
},
};
}
+363
View File
@@ -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();
});