From c1d829ef511bd0652c155bdb0473ebb1edc1a19c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 03:28:26 +0400 Subject: [PATCH] Draw local infrastructure on either globe from one retained dataset --- scripts/package-boundaries.json | 4 + scripts/qa-infrastructure-engine-switch.mjs | 164 ++++++++ src/app/constructCatalog.js | 7 +- src/app/localGeojsonServices.js | 2 + src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 7 + src/data/infrastructure.js | 6 +- src/data/localGeojsonCore.js | 14 +- src/layers/infrastructure/index.js | 204 ++++++++++ src/layers/infrastructure/render/navara.js | 363 ++++++++++++++++++ .../infrastructure/render/navaraStems.js | 70 ++++ src/layers/infrastructure/session.test.mjs | 63 +++ 12 files changed, 900 insertions(+), 6 deletions(-) create mode 100644 scripts/qa-infrastructure-engine-switch.mjs create mode 100644 src/layers/infrastructure/index.js create mode 100644 src/layers/infrastructure/render/navara.js create mode 100644 src/layers/infrastructure/render/navaraStems.js create mode 100644 src/layers/infrastructure/session.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2b1e9e8e5..d413f11bb 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-infrastructure-engine-switch.mjs b/scripts/qa-infrastructure-engine-switch.mjs new file mode 100644 index 000000000..758a531b6 --- /dev/null +++ b/scripts/qa-infrastructure-engine-switch.mjs @@ -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(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 361e5351d..249e41f3d 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -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', diff --git a/src/app/localGeojsonServices.js b/src/app/localGeojsonServices.js index 8103f3b35..66189c3e5 100644 --- a/src/app/localGeojsonServices.js +++ b/src/app/localGeojsonServices.js @@ -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, diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 05d00fa8a..8c1712bc5 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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', ], ); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 13509661c..4258d85cc 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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); diff --git a/src/data/infrastructure.js b/src/data/infrastructure.js index 348d4bfb4..27b8ae93e 100644 --- a/src/data/infrastructure.js +++ b/src/data/infrastructure.js @@ -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, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 1d04fa46c..dc473ce5e 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -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; diff --git a/src/layers/infrastructure/index.js b/src/layers/infrastructure/index.js new file mode 100644 index 000000000..0ff43354e --- /dev/null +++ b/src/layers/infrastructure/index.js @@ -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; + }, + }; +} diff --git a/src/layers/infrastructure/render/navara.js b/src/layers/infrastructure/render/navara.js new file mode 100644 index 000000000..38c4fae70 --- /dev/null +++ b/src/layers/infrastructure/render/navara.js @@ -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), + }), + }; +} diff --git a/src/layers/infrastructure/render/navaraStems.js b/src/layers/infrastructure/render/navaraStems.js new file mode 100644 index 000000000..319eb74f5 --- /dev/null +++ b/src/layers/infrastructure/render/navaraStems.js @@ -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(), + }; +} diff --git a/src/layers/infrastructure/session.test.mjs b/src/layers/infrastructure/session.test.mjs new file mode 100644 index 000000000..3280d33a9 --- /dev/null +++ b/src/layers/infrastructure/session.test.mjs @@ -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(); +});