diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index bea6e46e0..a76682116 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2372,6 +2372,7 @@ "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", + "src/layers/submarineCables/session.js", "src/layers/submarineCables/ingestion.js", "src/layers/submarineCables/interaction.js", "src/layers/submarineCables/lifecycle.js", @@ -2960,6 +2961,7 @@ "src/layers/submarineCables/bundledSource.js", "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", + "src/layers/submarineCables/session.js", "src/layers/submarineCables/ingestion.js", "src/layers/submarineCables/interaction.js", "src/layers/submarineCables/lifecycle.js", @@ -3674,7 +3676,8 @@ "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/shared/render/navaraStems.js", + "src/layers/submarineCables/render/navara.js", "src/layers/weather/render/navara.js", "src/layers/weather/render/navaraSurface.js", "src/layers/wind/render/navara.js", @@ -4033,6 +4036,7 @@ "src/layers/satellites/tracking.js", "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", + "src/layers/submarineCables/session.js", "src/layers/submarineCables/ingestion.js", "src/layers/submarineCables/interaction.js", "src/layers/submarineCables/lifecycle.js", diff --git a/scripts/qa-submarine-cables-engine-switch.mjs b/scripts/qa-submarine-cables-engine-switch.mjs new file mode 100644 index 000000000..005684ae0 --- /dev/null +++ b/scripts/qa-submarine-cables-engine-switch.mjs @@ -0,0 +1,115 @@ +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=55.3241&lon=10.7995&alt=25000&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 = []; + p.on('request', (req) => { + const path = new URL(req.url()).pathname; + if (/\/(cable-geo|landing-point-geo).*\.json$/.test(path)) reads.push(path); + }); + p.on('console', (msg) => { + if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) + errors.push(msg.text()); + }); + 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, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Keyless preview required'); + window.qaCables = dataManager.layers.get( + 'telegeography-submarine-cables', + ).module; + await dataManager.setEnabled(window.qaCables.id, true, { origin: 'user' }); + }); + for (const kind of ['navara', 'cesium', 'navara']) { + if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { + await p.select('#globe-engine', kind); + await ready(kind); + } + await p.waitForFunction( + () => window.qaCables.getStats().referenceLabelCount > 0, + { timeout: 90000 }, + ); + const check = await p.evaluate(() => { + const { dataManager } = window.__godsEyeView; + const mod = dataManager.layers.get( + 'telegeography-submarine-cables', + ).module; + return { same: mod === window.qaCables, ...mod.getStats() }; + }); + console.log(kind, check); + assert.equal(check.same, true); + assert.equal(check.count, 2629); + assert.ok(check.referenceLabelCount <= 160); + assert.equal(check.error, null); + if (kind === 'navara') { + const screen = await p.evaluate( + (world) => { + const { engine } = window.__godsEyeView; + const at = engine.projector.worldToWindow(world, {}); + const rect = engine.host.canvas.getBoundingClientRect(); + return { x: rect.left + at.x, y: rect.top + at.y }; + }, + fromDegrees(10.799480647390464, 55.324102015186114), + ); + await p.mouse.click(screen.x, screen.y); + await p.waitForFunction( + () => { + const c = window.__godsEyeView.engine.camera.pose(); + return ( + Math.abs(c.cartographic.height - 6500) < 80 && + Math.abs(c.pitch + (52 * Math.PI) / 180) < 0.03 + ); + }, + { timeout: 20000 }, + ); + } + } + assert.equal(reads.length, 2, JSON.stringify(reads)); + assert.deepEqual(errors, []); + console.log( + 'PASS full bundled cable dataset, bounded labels, landing-point fly-to and one read per file', + ); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7591be79c..fae2a2870 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -315,7 +315,7 @@ export function createApplicationCatalog({ ], [ 'telegeography-submarine-cables', - () => createApplicationCables({ source: sources.cables }), + () => sessionModel('telegeography-submarine-cables', () => createApplicationCables({ source: sources.cables, session: true })), ], [ 'local-firms', diff --git a/src/app/layers/submarineCables.js b/src/app/layers/submarineCables.js index b84c072db..c90926a02 100644 --- a/src/app/layers/submarineCables.js +++ b/src/app/layers/submarineCables.js @@ -1,13 +1,22 @@ import * as Cesium from 'cesium'; import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js'; +import { createCableSessionLayer } from '../../layers/submarineCables/session.js'; +import { rendererFor } from './renderers.js'; import { overlayHost } from './overlayHost.js'; /** Wire cable source geometry to the application overlay host. */ -export function createApplicationCables(options) { - return createSubmarineCableLayer({ +export function createApplicationCables({ session = false, ...options }) { + const createCesium = (source) => createSubmarineCableLayer({ overlayHost, screenSpaceEventHandlerFactory: (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), mapStackEventTarget: typeof window !== 'undefined' ? window : null, ...options, + source, + }); + if (!session) return createCesium(options.source); + return createCableSessionLayer({ + source: options.source, + createCesium, + createNavara: (engine) => rendererFor('telegeography-submarine-cables', engine, { overlayHost }), }); } diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index fb24895bb..bed92bb97 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -69,6 +69,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'weather-cyclones', 'local-datacenters', 'local-dams', + 'telegeography-submarine-cables', 'local-firms', ], ); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 44a7ed255..65a76112d 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -59,6 +59,7 @@ const NAVARA_CREDIT_KEYS = new Set([ 'eccc-aqhi', 'wfigs', 's2-ghostmaps', + 'telegeography', 'wind-gfs', 'ecmwf-ifs', 'weather-noaa', @@ -119,6 +120,10 @@ export async function registerNavaraLayerRenderers() { return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options); }, ])), + 'telegeography-submarine-cables': async () => { + const { createNavaraCables } = await import('../layers/submarineCables/render/navara.js'); + return (engine, { overlayHost }) => createNavaraCables(engine, navaraViews.get(engine), overlayHost); + }, wind: async () => { const { createWindNavaraRenderer } = await import('../layers/wind/render/navara.js'); return (engine) => createWindNavaraRenderer(engine, navaraViews.get(engine)); diff --git a/src/layers/infrastructure/render/navara.js b/src/layers/infrastructure/render/navara.js index 38c4fae70..b6a8f4757 100644 --- a/src/layers/infrastructure/render/navara.js +++ b/src/layers/infrastructure/render/navara.js @@ -17,7 +17,7 @@ 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'; +import { createStemBatch } from '../../shared/render/navaraStems.js'; export function createInfrastructureNavaraRenderer( engine, @@ -263,7 +263,7 @@ export function createInfrastructureNavaraRenderer( depthTest: 'always', point: { sizePx: 10, outlinePx: 2, outlineColor: [0, 0, 0, 1] }, }); - stems = createStemBatch(native, color); + stems = createStemBatch(native, color, INFRA_LOD_ACTIVE_MAX); for (const input of next.records) { const carto = geo.cartographic.fromDegrees(input.lon, input.lat); const base = geo.cartesian3.fromDegrees(input.lon, input.lat), diff --git a/src/layers/infrastructure/render/navaraStems.js b/src/layers/shared/render/navaraStems.js similarity index 89% rename from src/layers/infrastructure/render/navaraStems.js rename to src/layers/shared/render/navaraStems.js index 319eb74f5..01b0e9a64 100644 --- a/src/layers/infrastructure/render/navaraStems.js +++ b/src/layers/shared/render/navaraStems.js @@ -1,12 +1,11 @@ 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) { +export function createStemBatch({ view, MeshDesc }, color, capacity, width = 3.5, opacity = 1) { if (!views.has(view)) { view.registerMesh( KEY, @@ -29,12 +28,13 @@ export function createStemBatch({ view, MeshDesc }, color) { ); views.add(view); } - const xyz = new Float32Array(6 * INFRA_LOD_ACTIVE_MAX); + const xyz = new Float32Array(6 * capacity); const geometry = new LineSegmentsGeometry().setPositions(xyz); geometry.instanceCount = 0; const material = new LineMaterial({ color, - linewidth: 3.5, + linewidth: width, + opacity, depthWrite: false, transparent: true, toneMapped: false, diff --git a/src/layers/submarineCables/lifecycle.js b/src/layers/submarineCables/lifecycle.js index 39e41f630..233bb248d 100644 --- a/src/layers/submarineCables/lifecycle.js +++ b/src/layers/submarineCables/lifecycle.js @@ -68,7 +68,7 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) { // The gate is dirty-only; ask for the frame its sweep needs in case the // camera is parked and nothing else is rendering. (viewer || state._viewer)?.scene?.requestRender?.(); - void parts.ingestion.load(viewer || state._viewer); + return parts.ingestion.load(viewer || state._viewer); }, disable() { diff --git a/src/layers/submarineCables/overlay.js b/src/layers/submarineCables/overlay.js index d42e7c934..04eea35ee 100644 --- a/src/layers/submarineCables/overlay.js +++ b/src/layers/submarineCables/overlay.js @@ -260,7 +260,7 @@ export function createCableReferenceSweepGate({ * @returns {boolean} True when the stem geometry was redefined. */ -export function updateCableReferenceStem( +export function updateCableReferenceTip( record, cameraPositionWC, canvasHeight, @@ -289,6 +289,11 @@ export function updateCableReferenceStem( return false; } geo.cartesian3.clone(record.nextTip, record.tip); + return true; +} + +export function updateCableReferenceStem(record, cameraPositionWC, canvasHeight, fov) { + if (!updateCableReferenceTip(record, cameraPositionWC, canvasHeight, fov)) return false; record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex; const stemPositions = record.stemPositionBuffers[record.stemPositionBufferIndex]; diff --git a/src/layers/submarineCables/render/navara.js b/src/layers/submarineCables/render/navara.js new file mode 100644 index 000000000..a7d33d162 --- /dev/null +++ b/src/layers/submarineCables/render/navara.js @@ -0,0 +1,268 @@ +import * as geo from '../../../geo/index.js'; +import { Color } from 'three'; +import { isPointerFree } from '../../../data/inputOwnership.js'; +import { + featureLabel, + featureReference, + normalizeFeatures, +} from '../geometry.js'; +import { + BASE_CABLE_COLOR, + BASE_LANDING_COLOR, + CABLE_OVERLAY_SOURCE_ID as id, +} from '../policy.js'; +import { + cableReferencePriority, + clampLabel, + createCableOverlayEntry, + createCableOverlayPublisher, + createCableReferenceSweepGate, + selectCableReferenceLabelWinners, + updateCableReferenceTip, +} from '../overlay.js'; +import { createStemBatch } from '../../shared/render/navaraStems.js'; +import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { distanceToSegment } from '../../../globe/cpuPicks.js'; + +export function createNavaraCables(engine, native, host) { + const { view } = native; + const publisher = createCableOverlayPublisher({ host }), + gate = createCableReferenceSweepGate(); + const records = new Map(), + banks = [], + removers = []; + let source, + layer, + dataset, + lastLabels = null, + referenceLabelCount = 0; + const wake = () => engine.render.request(id); + function sweep() { + const position = engine.camera.livePosition; + if (!position || !gate.shouldRun({ positionWC: position })) return; + const { height } = engine.host.size(), + { fovy } = engine.camera.pose(); + for (const bank of banks) { + const visible = []; + for (const row of bank.rows) { + row.visible = engine.projector.horizonVisible(row.base); + if (!row.visible) continue; + row.distanceM = geo.cartesian3.distance(position, row.base); + updateCableReferenceTip(row, position, height, fovy); + bank.points.set(visible.length, { + position: row.tip, + pickId: row.id, + show: true, + color: bank.color, + }); + visible.push(row); + } + bank.points.setCount(visible.length); + bank.points.commit(); + bank.stems.set(visible, position); + } + const winners = selectCableReferenceLabelWinners([...records.values()]); + referenceLabelCount = winners.length; + const signature = winners + .map((r) => r.id + '/' + cableReferencePriority(r.distanceM)) + .join('|'); + if (signature !== lastLabels) { + lastLabels = signature; + publisher.publish( + winners.map((r) => { + r.entry.priority = cableReferencePriority(r.distanceM); + return r.entry; + }), + ); + } + } + function hide() { + for (const fn of removers.splice(0)) fn(); + for (const bank of banks.splice(0)) { + bank.points?.destroy(); + bank.stems?.destroy(); + bank.surface?.destroy(); + } + layer?.delete(); + source?.delete(); + source = layer = dataset = null; + records.clear(); + gate.reset(); + lastLabels = null; + referenceLabelCount = 0; + publisher.hide(); + wake(); + } + function show(next) { + if (next === dataset) return; + if (dataset) hide(); + try { + const cables = normalizeFeatures(next.cables, 'cable'), + landings = normalizeFeatures(next.landingPoints, 'landing'); + const shapes = [], + colors = new Map(); + for (const [kind, features, css] of [ + ['cable', cables, BASE_CABLE_COLOR], + ['landing-point', landings, BASE_LANDING_COLOR], + ]) { + const rows = [], + color = rgbaFromCss(css).rgba(); + for (const f of features) { + const ref = featureReference(f); + if (!ref) continue; + const key = id + ':' + kind + ':' + rows.length; + const base = geo.cartesian3.fromDegrees(ref.lon, ref.lat); + const row = { + id: key, + reference: ref, + label: clampLabel(featureLabel(f)), + kind, + base, + tip: geo.cartesian3.fromDegrees(ref.lon, ref.lat, 2500), + nextTip: geo.cartesian3.create(), + }; + row.entry = createCableOverlayEntry(row); + records.set(key, row); + rows.push(row); + const geom = f.geometry; + if (geom.type === 'Point') + shapes.push({ + id: key, + geometry: { type: 'point', position: geom.coordinates }, + style: { pointRadius: 5 }, + }); + else + for (const path of geom.type === 'MultiLineString' + ? geom.coordinates + : [geom.coordinates]) + if (path.length > 1) + shapes.push({ + id: key, + geometry: { type: 'line', positions: path }, + style: { lineWidth: 2.5 }, + }); + } + if (!rows.length) continue; + const bank = { rows, color }; + banks.push(bank); + const point = { + sizePx: kind === 'cable' ? 7 : 8, + outlinePx: 1, + outlineColor: [0, 0, 0, 0.65], + }; + const make = (role) => + engine.movers.createSet({ + id: id + '-' + kind + role, + kind: 'point', + capacity: rows.length, + depthTest: 'normal', + pickable: true, + point, + }); + bank.points = make('-tip'); + bank.stems = createStemBatch( + native, + css, + rows.length, + kind === 'cable' ? 2 : 2.4, + kind === 'cable' ? 0.58 : 0.68, + ); + if (kind === 'landing-point') { + bank.surface = make('-base'); + rows.forEach((row, i) => + bank.surface.set(i, { + position: row.base, + show: true, + pickId: row.id, + color, + }), + ); + bank.surface.setCount(rows.length); + bank.surface.commit(); + } + } + source = view.addSource({ + type: 'geojson', + data: { type: 'FeatureCollection', features: cables }, + }); + layer = view.addLayer({ + type: 'vector', + source, + polyline: { + clampToGround: true, + width: 2.5, + lit: false, + color: 0xffffff, + opacity: 0.92, + transparent: true, + }, + }); + layer.on('featureUpdated', ({ evaluator }) => + evaluator.evaluate(({ properties }) => { + const css = properties?.color ?? BASE_CABLE_COLOR; + if (!colors.has(css)) colors.set(css, new Color(css)); + return { color: colors.get(css) }; + }), + ); + layer.forceUpdate(); + removers.push(bindGroundShapePicks(engine, id, shapes)); + removers.push( + engine.picker.registerMeshPicker((at) => { + const hits = []; + for (const r of records.values()) { + if (!r.visible) continue; + const a = engine.projector.worldToWindow(r.base, {}), + b = engine.projector.worldToWindow(r.tip, {}); + if (a && b && distanceToSegment(at, a, b) <= 2) + hits.push({ + onTop: 0, + order: 0, + depth: r.distanceM, + result: { id: r.id, owner: id }, + }); + } + return hits; + }), + ); + removers.push( + engine.input.on( + 'click', + (e) => { + if (!isPointerFree()) return; + const row = records.get(e.picked?.id); + if (!row) return; + engine.camera.flyTo({ + destination: geo.cartesian3.fromDegrees( + row.reference.lon, + row.reference.lat, + 6500, + ), + orientation: { + heading: engine.camera.pose().heading, + pitch: geo.math.toRadians(-52), + roll: 0, + }, + duration: 1.35, + }); + }, + { pick: true }, + ), + engine.camera.moveEnd.addEventListener(() => { + gate.markDirty(); + wake(); + }), + engine.frames.preRender.addEventListener(sweep), + ); + dataset = next; + publisher.show(); + gate.markDirty(); + sweep(); + wake(); + } catch (e) { + hide(); + throw e; + } + } + return { show, hide, destroy: hide, stats: () => ({ referenceLabelCount }) }; +} diff --git a/src/layers/submarineCables/session.js b/src/layers/submarineCables/session.js new file mode 100644 index 000000000..12a132f0c --- /dev/null +++ b/src/layers/submarineCables/session.js @@ -0,0 +1,139 @@ +/** The dataset is acquired once per activation, independently of scene attachment. */ +export function createCableSessionLayer({ + source, + createCesium, + createNavara, +}) { + const id = 'telegeography-submarine-cables'; + let data, + request, + controller, + display, + enabled = false, + disposed = false, + updated = null, + error = null; + function read() { + if (data) return Promise.resolve(data); + if (!request) { + const token = new AbortController(); + controller = token; + request = source + .fetch(token.signal) + .then((result) => { + token.signal.throwIfAborted(); + if ( + !Array.isArray(result?.cables?.features) || + !Array.isArray(result?.landingPoints?.features) + ) + throw Error('Invalid submarine cable dataset'); + updated = Date.now(); + return (data = result); + }) + .finally(() => { + if (controller === token) { + controller = request = null; + } + }); + } + return request; + } + let rendering = null; + function show() { + if (rendering) return rendering; + const owner = display; + if (!owner) return; + const result = (async () => { + try { + const next = await read(); + if (enabled && display === owner && !disposed) { + error = null; + await owner.show(next); + } + } catch (e) { + if ( + !disposed && + enabled && + owner === display && + e?.name !== 'AbortError' + ) + error = e.message; + } + })(); + rendering = result; + return result.finally(() => { + if (rendering === result) rendering = null; + }); + } + return { + id, + name: 'Submarine Cables', + icon: '≋', + source: source.label, + updateInterval: 0, + statsRefreshInterval: 500, + init(viewer, ctx) { + return this.attachRenderer(viewer, ctx); + }, + attachRenderer(viewer, { engine } = {}) { + if (viewer) { + const legacy = createCesium({ label: source.label, fetch: read }); + legacy.init(viewer); + let shown = false; + display = { + async show() { + if (!shown) { + await legacy.enable(viewer); + shown = !legacy.getStats().error; + } else if (!legacy.getStats().count) await legacy.update(viewer); + }, + hide() { + shown = false; + legacy.disable(); + }, + destroy: () => legacy.destroy(viewer), + stats: () => legacy.getStats(), + }; + } else display = createNavara(engine); + if (enabled) return show(); + }, + detachRenderer() { + const owner = display; + display = rendering = null; + owner?.destroy(); + }, + enable() { + enabled = true; + return show(); + }, + update() { + if (enabled) return show(); + }, + disable() { + enabled = false; + controller?.abort(); + controller = request = rendering = null; + error = null; + display?.hide(); + }, + destroy() { + this.disable(); + this.detachRenderer(); + disposed = true; + data = null; + }, + getStats() { + const s = display?.stats?.() ?? {}; + return { + ...s, + count: + enabled && data + ? data.cables.features.length + data.landingPoints.features.length + : 0, + loading: Boolean(request) || Boolean(s.loading), + lastUpdate: updated, + error: error ?? s.error ?? null, + }; + }, + }; +} diff --git a/src/layers/submarineCables/session.test.mjs b/src/layers/submarineCables/session.test.mjs new file mode 100644 index 000000000..6aa7c56d9 --- /dev/null +++ b/src/layers/submarineCables/session.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createCableSessionLayer } from './session.js'; + +test('cables arriving during handoff belong to the incoming display', async () => { + let resolve, + reads = 0; + const shown = []; + const layer = createCableSessionLayer({ + source: { + label: 'fixture', + fetch: () => { + reads++; + return new Promise((done) => { + resolve = done; + }); + }, + }, + createNavara: (engine) => ({ + show: (dataset) => shown.push([engine, dataset]), + destroy() {}, + hide() {}, + }), + }); + layer.init(null, { engine: 'outgoing' }); + const pending = layer.enable(); + layer.detachRenderer(); + const handoff = layer.attachRenderer(null, { engine: 'incoming' }); + const dataset = { cables: { features: [] }, landingPoints: { features: [] } }; + resolve(dataset); + await Promise.all([pending, handoff]); + assert.deepEqual(shown, [['incoming', dataset]]); + assert.equal(reads, 1); + layer.destroy(); +});