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
+297 -100

No files matched your search

+36 -8
View File
@@ -1,7 +1,7 @@
import { SceneDirector } from '../scenes/director.js';
import { createSessionAnnotations } from '../annotations/index.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 { applicationPinia } from '../ui/vueRuntime.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
@@ -45,6 +45,28 @@ export function sessionAnnotations({
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) {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
@@ -152,17 +174,23 @@ export function createApplicationTools({
});
defer(() => drawTool?.destroy());
// 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
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
// own data source. They live for the page session: an engine switch takes
// them off this viewer, and the next Cesium viewer reads them again. The
// drop listeners belong to this viewer.
const geoFileImport = sessionTool(layerSession, 'geoImport', defer, () =>
createGeoFileImport({
navigation: styleManager,
ground: operations.surface?.groundFloor,
publish: (view) => displayStore.publishGeoImport(view),
connect: connectDisplay,
}),
);
geoFileImport?.attachScene({
viewer,
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
publish: (view) => displayStore.publishGeoImport(view),
connect: connectDisplay,
});
defer(() => geoFileImport?.destroy());
defer(() => geoFileImport?.detachScene());
// 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
// lifetime. The tileset lets the layer drape while the globe is hidden.
+199 -90
View File
@@ -21,19 +21,37 @@ function localizedValues(values) {
}
/**
* Run the Import control.
* @param {{viewer: object, load?: Function, document?: Document,
* publish?: Function, connect?: Function}} deps
* Run the Import control on one viewer: the session's control (see
* createGeoFileImport) attached to `viewer`. Null without a viewer or the
* 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.
* `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
* 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,
* setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function}|null}
* diagnostics: Function, attachScene: Function, detachScene: Function}|null}
*/
export function initGeoFileImport({
viewer,
export function createGeoFileImport({
load = loadGeoFile,
document: doc = globalThis.document,
flyToSource = flyToDataSource,
@@ -42,13 +60,13 @@ export function initGeoFileImport({
subscribeMapSource = null,
publish = () => {},
connect = () => () => {},
}) {
} = {}) {
// A page without the Import control (the Utilities panel omitted, in
// 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 listeners = [];
let listeners = [];
let queue = Promise.resolve();
let seq = 0;
let colorAt = 0;
@@ -57,7 +75,9 @@ export function initGeoFileImport({
let cameraIntent = 0;
let hint = '';
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) => {
if (!target?.addEventListener) return;
@@ -68,13 +88,13 @@ export function initGeoFileImport({
hint = text || '';
present();
};
const render = () => viewer.scene?.requestRender?.();
const render = () => viewer?.scene?.requestRender?.();
let groundTimer,
groundQueued = false,
groundAgain = false;
const groundRemovers = [];
let groundRemovers = [];
const scheduleGround = () => {
if (destroyed || !imports.length) return;
if (destroyed || !viewer || !imports.length) return;
if (groundQueued) {
groundAgain = true;
return;
@@ -82,17 +102,19 @@ export function initGeoFileImport({
clearTimeout(groundTimer);
groundTimer = setTimeout(() => {
groundQueued = true;
const attached = scene;
queue = queue
.catch(() => {})
.then(async () => {
try {
for (const item of imports) {
if (destroyed) return;
if (item.visible) await item.refreshGround?.(lifetime.signal);
if (destroyed || scene !== attached) return;
if (item.visible)
await item.refreshGround?.(attached.controller.signal);
}
render();
} catch {
if (!destroyed)
if (!destroyed && scene === attached)
setHint(
t(
'Ground heights could not be refreshed. Try the file again when the map has loaded.',
@@ -108,15 +130,10 @@ export function initGeoFileImport({
});
}, 200);
};
removeMapSource = subscribeMapSource?.(scheduleGround);
const ownEvent = (event, listener) => {
const remove = event?.addEventListener?.(listener);
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. */
function present() {
@@ -124,8 +141,6 @@ export function initGeoFileImport({
rows: imports.map((item) => ({
id: item.id,
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,
color: item.color,
title: `${item.name} — ${item.format.toUpperCase()}, ${tn(
@@ -166,16 +181,15 @@ export function initGeoFileImport({
const item = find(id);
if (!item) return false;
item.visible = Boolean(visible);
item.dataSource.show = item.visible;
if (item.dataSource) item.dataSource.show = item.visible;
if (item.visible) scheduleGround();
present();
render();
return true;
}
// Not viewer.flyTo: it frames ground-clamped points underground until the
// destination's terrain has loaded (see ./flyToDataSource.js).
const fly = (item) => {
if (!viewer || !item.dataSource) return false;
if (!item.visible) setVisible(item.id, true);
flyToSource(viewer, item.dataSource, { ground });
return true;
@@ -187,7 +201,7 @@ export function initGeoFileImport({
*/
function flyTo(id) {
const item = find(id);
if (!item) return false;
if (!item || !viewer || !item.dataSource) return false;
if (typeof navigation?.runImmediateNavigation === 'function')
return (
navigation.runImmediateNavigation('file', () => fly(item)) !== false
@@ -209,18 +223,41 @@ export function initGeoFileImport({
: 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. */
function remove(id) {
const at = imports.findIndex((item) => item.id === id);
if (at < 0) return false;
const [item] = imports.splice(at, 1);
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
undraw(item);
present();
render();
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.
* @param {Iterable<File>} files
@@ -230,20 +267,33 @@ export function initGeoFileImport({
function importFiles(files) {
const list = [...(files || [])];
if (destroyed || !list.length) return Promise.resolve([]);
// Capture the user's intent when the files are chosen, including time
// spent waiting for an earlier batch. Later navigation must still win.
if (!viewer) {
// 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 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(() => {});
return run;
}
async function importBatch(list, generation, intent) {
async function importBatch(list, generation, intent, attached) {
const outcomes = [];
if (destroyed || !list.length) return outcomes;
for (const file of list) {
if (destroyed) break;
if (destroyed || scene !== attached) break;
const fileName = String(file?.name || 'file');
if (imports.length >= MAX_IMPORTED_FILES) {
outcomes.push({
@@ -259,7 +309,11 @@ export function initGeoFileImport({
const color = COLORS[colorAt % COLORS.length];
let loaded;
try {
loaded = await load(file, { viewer, color, signal: lifetime.signal });
loaded = await load(file, {
viewer: attached.viewer,
color,
signal: attached.controller.signal,
});
} catch (error) {
outcomes.push({
name: fileName,
@@ -274,7 +328,7 @@ export function initGeoFileImport({
});
continue;
}
if (destroyed) {
if (destroyed || scene !== attached) {
loaded.revoke?.();
break;
}
@@ -289,17 +343,13 @@ export function initGeoFileImport({
removed: loaded.removed || 0,
color,
visible: true,
dataSource: loaded.dataSource,
revoke: loaded.revoke,
refreshGround: loaded.refreshGround,
// The bytes, so a later viewer can read the file again.
file,
dataSource: null,
revoke: null,
refreshGround: null,
};
await viewer.dataSources.add(item.dataSource);
if (destroyed) {
viewer.dataSources.remove?.(item.dataSource, true);
item.revoke?.();
break;
}
loaded.mount?.();
if (!(await draw(item, attached, loaded))) break;
imports.push(item);
present();
render();
@@ -309,8 +359,6 @@ export function initGeoFileImport({
id: item.id,
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 (
outcomes.filter((o) => o.ok).length === 1 &&
intent === cameraIntent &&
@@ -318,12 +366,37 @@ export function initGeoFileImport({
)
fly(item);
}
if (!destroyed) setHint(summarize(outcomes));
if (!destroyed && scene === attached) setHint(summarize(outcomes));
return outcomes;
}
// ---- controls ----------------------------------------------------------
// The view opens the picker; its pick and the row buttons call these.
// Draw the files kept while no viewer was attached, as they were.
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({
importGeoFiles: (files) => void importFiles(files),
toggleGeoImport: (id) => setVisible(id, !find(id)?.visible),
@@ -331,45 +404,83 @@ export function initGeoFileImport({
removeGeoImport: remove,
});
// ---- drop onto the globe ----------------------------------------------
const container = viewer.container;
const gesture = () => {
cameraIntent += 1;
};
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
passive: true,
});
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
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) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth += 1;
setDragging(true);
});
listen(doc, 'dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
});
listen(container, 'dragleave', (event) => {
if (!hasFiles(event)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (!dragDepth) setDragging(false);
});
listen(doc, 'drop', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
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);
const files = [...(event.dataTransfer?.files || [])];
if (files.length) void importFiles(files);
});
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, {
passive: true,
});
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
passive: true,
});
listen(container, 'dragenter', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth += 1;
setDragging(true);
});
listen(doc, 'dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
});
listen(container, 'dragleave', (event) => {
if (!hasFiles(event)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (!dragDepth) setDragging(false);
});
listen(doc, 'drop', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth = 0;
setDragging(false);
const files = [...(event.dataTransfer?.files || [])];
if (files.length) void importFiles(files);
});
if (imports.some((item) => !item.dataSource)) {
const run = queue.then(() => redraw(attached));
queue = run.catch(() => {});
}
}
present();
@@ -387,26 +498,24 @@ export function initGeoFileImport({
setVisible,
flyTo,
remove,
attachScene,
detachScene,
diagnostics: () => ({
files: imports.length,
drawn: imports.filter((item) => item.dataSource).length,
entities: imports.reduce((n, item) => n + item.entityCount, 0),
listeners: listeners.length,
attached: Boolean(viewer),
destroyed,
}),
destroy() {
if (destroyed) return;
const last = viewer;
detachScene();
destroyed = true;
disconnect();
lifetime.abort();
removeMapSource?.();
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);
for (const item of imports.splice(0)) undraw(item, last);
setDragging(false);
setHint('');
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',
);
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);
// Fly-to goes through the application shell's navigation policy.
assert.match(
tools.slice(importAt, importAt + 200),
/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.
assert.match(
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();
});
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();
});