Keep imported files across engine switches and read them again on the next Cesium viewer

The Import list and each file's bytes belong to the page session; the data sources, pins, drop and gesture listeners belong to the attached viewer. Without a viewer the list stays and import or fly-to is refused.
This commit is contained in:
German Khokhlov committed 2026-11-03 17:30:27 +04:00
1 parent 111ba1626c
commit 17eea266f1
3 files changed
+272 -75

No files matched your search

+35 -7
View File
@@ -1,7 +1,7 @@
import { SceneDirector } from '../scenes/director.js'; import { SceneDirector } from '../scenes/director.js';
import { createSessionAnnotations } from '../annotations/index.js'; import { createSessionAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js'; import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js'; import { createGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js'; import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js'; import { applicationPinia } from '../ui/vueRuntime.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
@@ -45,6 +45,28 @@ export function sessionAnnotations({
return layerSession.annotations; return layerSession.annotations;
} }
/**
* A tool of the page session (created once, destroyed with the page) when
* there is one, else of this application. `create()` may return null.
*/
function sessionTool(layerSession, name, defer, create) {
if (!layerSession) {
const tool = create();
defer(() => tool?.destroy());
return tool;
}
const tools = (layerSession.tools ??= {});
if (!(name in tools)) {
const tool = (tools[name] = create());
layerSession.lifetime.signal.addEventListener(
'abort',
() => tool?.destroy(),
{ once: true },
);
}
return tools[name];
}
function destroyAnnotations(annotations) { function destroyAnnotations(annotations) {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy(); annotations.destroy();
@@ -152,17 +174,23 @@ export function createApplicationTools({
}); });
defer(() => drawTool?.destroy()); defer(() => drawTool?.destroy());
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its // UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application // own data source. They live for the page session: an engine switch takes
// lifetime removes them and the drop listeners on the viewer container. // them off this viewer, and the next Cesium viewer reads them again. The
const geoFileImport = initGeoFileImport({ // drop listeners belong to this viewer.
viewer, const geoFileImport = sessionTool(layerSession, 'geoImport', defer, () =>
createGeoFileImport({
navigation: styleManager, navigation: styleManager,
ground: operations.surface?.groundFloor, ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
publish: (view) => displayStore.publishGeoImport(view), publish: (view) => displayStore.publishGeoImport(view),
connect: connectDisplay, connect: connectDisplay,
}),
);
geoFileImport?.attachScene({
viewer,
navigation: styleManager,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
}); });
defer(() => geoFileImport?.destroy()); defer(() => geoFileImport?.detachScene());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application // panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden. // lifetime. The tileset lets the layer drape while the globe is hidden.
+175 -66
View File
@@ -21,19 +21,37 @@ function localizedValues(values) {
} }
/** /**
* Run the Import control. * Run the Import control on one viewer: the session's control (see
* @param {{viewer: object, load?: Function, document?: Document, * createGeoFileImport) attached to `viewer`. Null without a viewer or the
* publish?: Function, connect?: Function}} deps * Import markup.
*/
export function initGeoFileImport({ viewer, ...options }) {
if (!viewer) return null;
const tool = createGeoFileImport(options);
tool?.attachScene({ viewer });
return tool;
}
/**
* The Import control for the page session.
* @param {{load?: Function, document?: Document, publish?: Function,
* connect?: Function}} deps
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double. * `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
* `publish({rows, hint})` hands the file list and hint to the view that * `publish({rows, hint})` hands the file list and hint to the view that
* renders them. `connect(actions)` answers the view's pick and row buttons * renders them. `connect(actions)` answers the view's pick and row buttons
* and returns their disconnect. * and returns their disconnect.
*
* The imported files belong to the session; their drawing belongs to the
* attached Cesium viewer. `attachScene({ viewer })` draws them (a file kept
* while no viewer was attached is read again from its bytes) and listens for
* drops and camera gestures there; `detachScene()` takes them off the viewer
* and keeps the list. Without a viewer (an engine without Cesium) the list
* stays, and import and fly-to wait for one.
* @returns {{destroy: Function, importFiles: Function, list: Function, * @returns {{destroy: Function, importFiles: Function, list: Function,
* setVisible: Function, flyTo: Function, remove: Function, * setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function}|null} * diagnostics: Function, attachScene: Function, detachScene: Function}|null}
*/ */
export function initGeoFileImport({ export function createGeoFileImport({
viewer,
load = loadGeoFile, load = loadGeoFile,
document: doc = globalThis.document, document: doc = globalThis.document,
flyToSource = flyToDataSource, flyToSource = flyToDataSource,
@@ -42,13 +60,13 @@ export function initGeoFileImport({
subscribeMapSource = null, subscribeMapSource = null,
publish = () => {}, publish = () => {},
connect = () => () => {}, connect = () => () => {},
}) { } = {}) {
// A page without the Import control (the Utilities panel omitted, in // A page without the Import control (the Utilities panel omitted, in
// LayerPanelsView) has no file import. // LayerPanelsView) has no file import.
if (!viewer || !doc?.getElementById('geo-import-button')) return null; if (!doc?.getElementById('geo-import-button')) return null;
const imports = []; const imports = [];
const listeners = []; let listeners = [];
let queue = Promise.resolve(); let queue = Promise.resolve();
let seq = 0; let seq = 0;
let colorAt = 0; let colorAt = 0;
@@ -57,7 +75,9 @@ export function initGeoFileImport({
let cameraIntent = 0; let cameraIntent = 0;
let hint = ''; let hint = '';
const lifetime = new AbortController(); const lifetime = new AbortController();
let removeMapSource; // The attached viewer and the lifetime of that attachment.
let viewer = null;
let scene = null;
const listen = (target, type, listener, options) => { const listen = (target, type, listener, options) => {
if (!target?.addEventListener) return; if (!target?.addEventListener) return;
@@ -68,13 +88,13 @@ export function initGeoFileImport({
hint = text || ''; hint = text || '';
present(); present();
}; };
const render = () => viewer.scene?.requestRender?.(); const render = () => viewer?.scene?.requestRender?.();
let groundTimer, let groundTimer,
groundQueued = false, groundQueued = false,
groundAgain = false; groundAgain = false;
const groundRemovers = []; let groundRemovers = [];
const scheduleGround = () => { const scheduleGround = () => {
if (destroyed || !imports.length) return; if (destroyed || !viewer || !imports.length) return;
if (groundQueued) { if (groundQueued) {
groundAgain = true; groundAgain = true;
return; return;
@@ -82,17 +102,19 @@ export function initGeoFileImport({
clearTimeout(groundTimer); clearTimeout(groundTimer);
groundTimer = setTimeout(() => { groundTimer = setTimeout(() => {
groundQueued = true; groundQueued = true;
const attached = scene;
queue = queue queue = queue
.catch(() => {}) .catch(() => {})
.then(async () => { .then(async () => {
try { try {
for (const item of imports) { for (const item of imports) {
if (destroyed) return; if (destroyed || scene !== attached) return;
if (item.visible) await item.refreshGround?.(lifetime.signal); if (item.visible)
await item.refreshGround?.(attached.controller.signal);
} }
render(); render();
} catch { } catch {
if (!destroyed) if (!destroyed && scene === attached)
setHint( setHint(
t( t(
'Ground heights could not be refreshed. Try the file again when the map has loaded.', 'Ground heights could not be refreshed. Try the file again when the map has loaded.',
@@ -108,15 +130,10 @@ export function initGeoFileImport({
}); });
}, 200); }, 200);
}; };
removeMapSource = subscribeMapSource?.(scheduleGround);
const ownEvent = (event, listener) => { const ownEvent = (event, listener) => {
const remove = event?.addEventListener?.(listener); const remove = event?.addEventListener?.(listener);
if (remove) groundRemovers.push(remove); if (remove) groundRemovers.push(remove);
}; };
ownEvent(viewer.camera?.moveEnd, scheduleGround);
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
if (!pending) scheduleGround();
});
/** Publish the file list and hint; the view renders them. */ /** Publish the file list and hint; the view renders them. */
function present() { function present() {
@@ -124,8 +141,6 @@ export function initGeoFileImport({
rows: imports.map((item) => ({ rows: imports.map((item) => ({
id: item.id, id: item.id,
name: item.name, name: item.name,
// KML and KMZ draw with their own styles; their swatch says so
// instead of claiming a color the globe does not show.
ownStyles: item.ownStyles, ownStyles: item.ownStyles,
color: item.color, color: item.color,
title: `${item.name} — ${item.format.toUpperCase()}, ${tn( title: `${item.name} — ${item.format.toUpperCase()}, ${tn(
@@ -166,16 +181,15 @@ export function initGeoFileImport({
const item = find(id); const item = find(id);
if (!item) return false; if (!item) return false;
item.visible = Boolean(visible); item.visible = Boolean(visible);
item.dataSource.show = item.visible; if (item.dataSource) item.dataSource.show = item.visible;
if (item.visible) scheduleGround(); if (item.visible) scheduleGround();
present(); present();
render(); render();
return true; return true;
} }
// Not viewer.flyTo: it frames ground-clamped points underground until the
// destination's terrain has loaded (see ./flyToDataSource.js).
const fly = (item) => { const fly = (item) => {
if (!viewer || !item.dataSource) return false;
if (!item.visible) setVisible(item.id, true); if (!item.visible) setVisible(item.id, true);
flyToSource(viewer, item.dataSource, { ground }); flyToSource(viewer, item.dataSource, { ground });
return true; return true;
@@ -187,7 +201,7 @@ export function initGeoFileImport({
*/ */
function flyTo(id) { function flyTo(id) {
const item = find(id); const item = find(id);
if (!item) return false; if (!item || !viewer || !item.dataSource) return false;
if (typeof navigation?.runImmediateNavigation === 'function') if (typeof navigation?.runImmediateNavigation === 'function')
return ( return (
navigation.runImmediateNavigation('file', () => fly(item)) !== false navigation.runImmediateNavigation('file', () => fly(item)) !== false
@@ -209,18 +223,41 @@ export function initGeoFileImport({
: true; : true;
}; };
/** Take one file's drawing off the viewer; the file stays in the list. */
function undraw(item, from = viewer) {
if (item.dataSource) from?.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
item.dataSource = null;
item.revoke = null;
item.refreshGround = null;
}
/** Remove one imported file from the globe and the list. */ /** Remove one imported file from the globe and the list. */
function remove(id) { function remove(id) {
const at = imports.findIndex((item) => item.id === id); const at = imports.findIndex((item) => item.id === id);
if (at < 0) return false; if (at < 0) return false;
const [item] = imports.splice(at, 1); const [item] = imports.splice(at, 1);
viewer.dataSources?.remove?.(item.dataSource, true); undraw(item);
item.revoke?.();
present(); present();
render(); render();
return true; return true;
} }
/** Read a file for the attached viewer. Null once that viewer went. */
async function draw(item, attached, loaded) {
item.dataSource = loaded.dataSource;
item.revoke = loaded.revoke;
item.refreshGround = loaded.refreshGround;
item.dataSource.show = item.visible;
await attached.viewer.dataSources.add(item.dataSource);
if (destroyed || scene !== attached) {
undraw(item, attached.viewer);
return false;
}
loaded.mount?.();
return true;
}
/** /**
* Import files, one after another. Returns one outcome per file. * Import files, one after another. Returns one outcome per file.
* @param {Iterable<File>} files * @param {Iterable<File>} files
@@ -230,20 +267,33 @@ export function initGeoFileImport({
function importFiles(files) { function importFiles(files) {
const list = [...(files || [])]; const list = [...(files || [])];
if (destroyed || !list.length) return Promise.resolve([]); if (destroyed || !list.length) return Promise.resolve([]);
// Capture the user's intent when the files are chosen, including time if (!viewer) {
// spent waiting for an earlier batch. Later navigation must still win. // Import draws on the Cesium globe; this engine has none.
const error = t('Action unavailable or cancelled');
setHint(error);
return Promise.resolve(
list.map((file) => ({
name: String(file?.name || 'file'),
ok: false,
error,
})),
);
}
const generation = beginAutoFly(); const generation = beginAutoFly();
const intent = ++cameraIntent; const intent = ++cameraIntent;
const run = queue.then(() => importBatch(list, generation, intent)); const attached = scene;
const run = queue.then(() =>
importBatch(list, generation, intent, attached),
);
queue = run.catch(() => {}); queue = run.catch(() => {});
return run; return run;
} }
async function importBatch(list, generation, intent) { async function importBatch(list, generation, intent, attached) {
const outcomes = []; const outcomes = [];
if (destroyed || !list.length) return outcomes; if (destroyed || !list.length) return outcomes;
for (const file of list) { for (const file of list) {
if (destroyed) break; if (destroyed || scene !== attached) break;
const fileName = String(file?.name || 'file'); const fileName = String(file?.name || 'file');
if (imports.length >= MAX_IMPORTED_FILES) { if (imports.length >= MAX_IMPORTED_FILES) {
outcomes.push({ outcomes.push({
@@ -259,7 +309,11 @@ export function initGeoFileImport({
const color = COLORS[colorAt % COLORS.length]; const color = COLORS[colorAt % COLORS.length];
let loaded; let loaded;
try { try {
loaded = await load(file, { viewer, color, signal: lifetime.signal }); loaded = await load(file, {
viewer: attached.viewer,
color,
signal: attached.controller.signal,
});
} catch (error) { } catch (error) {
outcomes.push({ outcomes.push({
name: fileName, name: fileName,
@@ -274,7 +328,7 @@ export function initGeoFileImport({
}); });
continue; continue;
} }
if (destroyed) { if (destroyed || scene !== attached) {
loaded.revoke?.(); loaded.revoke?.();
break; break;
} }
@@ -289,17 +343,13 @@ export function initGeoFileImport({
removed: loaded.removed || 0, removed: loaded.removed || 0,
color, color,
visible: true, visible: true,
dataSource: loaded.dataSource, // The bytes, so a later viewer can read the file again.
revoke: loaded.revoke, file,
refreshGround: loaded.refreshGround, dataSource: null,
revoke: null,
refreshGround: null,
}; };
await viewer.dataSources.add(item.dataSource); if (!(await draw(item, attached, loaded))) break;
if (destroyed) {
viewer.dataSources.remove?.(item.dataSource, true);
item.revoke?.();
break;
}
loaded.mount?.();
imports.push(item); imports.push(item);
present(); present();
render(); render();
@@ -309,8 +359,6 @@ export function initGeoFileImport({
id: item.id, id: item.id,
removed: item.removed, removed: item.removed,
}); });
// Fly to the first file of a batch that lands, so a drop shows its
// result, unless something else took the camera meanwhile.
if ( if (
outcomes.filter((o) => o.ok).length === 1 && outcomes.filter((o) => o.ok).length === 1 &&
intent === cameraIntent && intent === cameraIntent &&
@@ -318,12 +366,37 @@ export function initGeoFileImport({
) )
fly(item); fly(item);
} }
if (!destroyed) setHint(summarize(outcomes)); if (!destroyed && scene === attached) setHint(summarize(outcomes));
return outcomes; return outcomes;
} }
// ---- controls ---------------------------------------------------------- // Draw the files kept while no viewer was attached, as they were.
// The view opens the picker; its pick and the row buttons call these. async function redraw(attached) {
for (const item of [...imports]) {
if (destroyed || scene !== attached) return;
if (item.dataSource || !imports.includes(item)) continue;
try {
const loaded = await load(item.file, {
viewer: attached.viewer,
color: item.color,
signal: attached.controller.signal,
});
if (destroyed || scene !== attached || !imports.includes(item)) {
loaded.revoke?.();
return;
}
await draw(item, attached, loaded);
} catch {
if (destroyed || scene !== attached) return;
setHint(`${item.name}: ${t('That file could not be read.')}`);
}
}
if (!destroyed && scene === attached) {
present();
render();
}
}
const disconnect = connect({ const disconnect = connect({
importGeoFiles: (files) => void importFiles(files), importGeoFiles: (files) => void importFiles(files),
toggleGeoImport: (id) => setVisible(id, !find(id)?.visible), toggleGeoImport: (id) => setVisible(id, !find(id)?.visible),
@@ -331,21 +404,54 @@ export function initGeoFileImport({
removeGeoImport: remove, removeGeoImport: remove,
}); });
// ---- drop onto the globe ----------------------------------------------
const container = viewer.container;
const gesture = () => { const gesture = () => {
cameraIntent += 1; cameraIntent += 1;
}; };
const hasFiles = (event) =>
[...(event.dataTransfer?.types || [])].includes('Files');
const setDragging = (on) =>
doc.body?.classList?.toggle('gev-geo-import-drag', on);
function detachScene() {
if (!scene) return;
const previous = scene;
scene = null;
previous.controller.abort();
clearTimeout(groundTimer);
for (const remove of groundRemovers.splice(0)) remove();
for (const [target, type, listener, options] of listeners.splice(0))
target.removeEventListener(type, listener, options);
dragDepth = 0;
setDragging(false);
for (const item of imports) undraw(item, previous.viewer);
viewer = null;
}
function attachScene({
viewer: next = null,
navigation: nextNavigation = navigation,
subscribeMapSource: nextMapSource = subscribeMapSource,
} = {}) {
if (destroyed) return;
detachScene();
if (!next) return;
viewer = next;
// The shell and the map sources of this attachment.
navigation = nextNavigation;
const offMapSource = nextMapSource?.(scheduleGround);
if (typeof offMapSource === 'function') groundRemovers.push(offMapSource);
const attached = (scene = { viewer, controller: new AbortController() });
ownEvent(viewer.camera?.moveEnd, scheduleGround);
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
if (!pending) scheduleGround();
});
const container = viewer.container;
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, { listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
passive: true, passive: true,
}); });
listen(viewer.scene?.canvas || container, 'wheel', gesture, { listen(viewer.scene?.canvas || container, 'wheel', gesture, {
passive: true, passive: true,
}); });
const hasFiles = (event) =>
[...(event.dataTransfer?.types || [])].includes('Files');
const setDragging = (on) =>
doc.body?.classList?.toggle('gev-geo-import-drag', on);
listen(container, 'dragenter', (event) => { listen(container, 'dragenter', (event) => {
if (!hasFiles(event)) return; if (!hasFiles(event)) return;
event.preventDefault(); event.preventDefault();
@@ -370,6 +476,11 @@ export function initGeoFileImport({
const files = [...(event.dataTransfer?.files || [])]; const files = [...(event.dataTransfer?.files || [])];
if (files.length) void importFiles(files); if (files.length) void importFiles(files);
}); });
if (imports.some((item) => !item.dataSource)) {
const run = queue.then(() => redraw(attached));
queue = run.catch(() => {});
}
}
present(); present();
@@ -387,26 +498,24 @@ export function initGeoFileImport({
setVisible, setVisible,
flyTo, flyTo,
remove, remove,
attachScene,
detachScene,
diagnostics: () => ({ diagnostics: () => ({
files: imports.length, files: imports.length,
drawn: imports.filter((item) => item.dataSource).length,
entities: imports.reduce((n, item) => n + item.entityCount, 0), entities: imports.reduce((n, item) => n + item.entityCount, 0),
listeners: listeners.length, listeners: listeners.length,
attached: Boolean(viewer),
destroyed, destroyed,
}), }),
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
const last = viewer;
detachScene();
destroyed = true; destroyed = true;
disconnect(); disconnect();
lifetime.abort(); lifetime.abort();
removeMapSource?.(); for (const item of imports.splice(0)) undraw(item, last);
clearTimeout(groundTimer);
for (const remove of groundRemovers) remove();
for (const item of imports.splice(0)) {
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
}
for (const [target, type, listener, options] of listeners.splice(0))
target.removeEventListener(type, listener, options);
setDragging(false); setDragging(false);
setHint(''); setHint('');
if (globalThis.window?.__gevGeoImport === handle) if (globalThis.window?.__gevGeoImport === handle)
+62 -2
View File
@@ -329,14 +329,19 @@ test('the application composition wires the import and owns its teardown', () =>
'utf8', 'utf8',
); );
const drawAt = tools.indexOf('const drawTool = initDrawTool({'); const drawAt = tools.indexOf('const drawTool = initDrawTool({');
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({'); const importAt = tools.indexOf(
"const geoFileImport = sessionTool(layerSession, 'geoImport', defer, () =>",
);
assert.ok(importAt > drawAt && drawAt >= 0); assert.ok(importAt > drawAt && drawAt >= 0);
// Fly-to goes through the application shell's navigation policy. // Fly-to goes through the application shell's navigation policy.
assert.match( assert.match(
tools.slice(importAt, importAt + 200), tools.slice(importAt, importAt + 200),
/navigation: styleManager/, /navigation: styleManager/,
); );
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt); // The session owns the files; this viewer only their drawing.
assert.ok(
tools.indexOf('defer(() => geoFileImport?.detachScene());') > importAt,
);
// The list renders from the store and its controls answer through it. // The list renders from the store and its controls answer through it.
assert.match( assert.match(
tools.slice(importAt, importAt + 400), tools.slice(importAt, importAt + 400),
@@ -624,3 +629,58 @@ test('a refusal shows its counts in the language’s numerals, not the browser
); );
t.tool.destroy(); t.tool.destroy();
}); });
test('an engine switch keeps the files and draws them again on the next viewer', async () => {
const t = setup();
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
const [a, b] = rows(t);
press(t, b, 'toggle');
t.tool.detachScene();
// The drawing left the viewer; the list and its rows stayed.
assert.equal(t.removed.length, 2);
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
assert.deepEqual(
rows(t).map((row) => row.id),
[a.id, b.id],
);
assert.equal(t.viewer.container.listeners.length, 0);
assert.equal(t.document.listeners.length, 0);
assert.equal(t.tool.flyTo(a.id), false);
const refused = await t.tool.importFiles([file('c.geojson')]);
assert.equal(refused[0].ok, false);
assert.equal(t.tool.list().length, 2);
// The next viewer reads the kept files again, as they were.
const next = {
container: new FakeElement('div', 'cesiumContainer'),
scene: { requestRender() {} },
dataSources: {
added: [],
add: async (ds) => next.dataSources.added.push(ds),
remove() {},
},
};
t.loads.length = 0;
t.tool.attachScene({ viewer: next });
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
t.loads.map(({ file, opts }) => [file.name, opts.viewer === next]),
[
['a.geojson', true],
['b.geojson', true],
],
);
assert.equal(next.dataSources.added.length, 2);
assert.equal(next.dataSources.added[1].show, false, 'b stays hidden');
assert.deepEqual(
t.tool.list().map(({ id, visible }) => [id, visible]),
[
[a.id, true],
[b.id, false],
],
);
assert.ok(next.container.listeners.length > 0);
assert.equal(t.tool.diagnostics().drawn, 2);
t.tool.destroy();
});