Move the session annotation board, Draw and Import out of the Cesium tools into sessionTools.js and give Navara its tools seam

This commit is contained in:
German Khokhlov committed 2026-11-03 20:37:59 +04:00
1 parent dea10cac4f
commit f5c14ec30d
7 files changed
+207 -102

No files matched your search

+2
View File
@@ -2661,6 +2661,7 @@
"src/app/stateChannel.js",
"src/app/surfaceServices.js",
"src/app/tools.js",
"src/app/sessionTools.js",
"src/app/sessionVoice.js",
"src/app/sceneParts.js",
"src/app/shellPart.js",
@@ -3597,6 +3598,7 @@
"src/globe/navara/viewOptions.js",
"src/app/engineChoice.js",
"src/app/navaraMode.js",
"src/app/navaraTools.js",
"src/data/render/cesiumProjection.js",
"src/data/render/cesiumConstants.js",
"src/data/render/cesiumTrailRenderer.js",
+8 -2
View File
@@ -21,19 +21,25 @@ const tool = () => read('src/annotations/drawTool.js');
test('the application composition wires the draw tool and owns its teardown', () => {
const tools = read('src/app/tools.js');
const session = read('src/app/sessionTools.js');
const engineAt = tools.indexOf('const annotations = sessionAnnotations({');
const drawAt = tools.indexOf(
"const drawTool = sessionTool(layerSession, 'draw', defer, () =>\n createSessionDrawTool({\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),",
'const drawTool = sessionDrawTool({\n layerSession,\n annotations,',
);
assert.ok(engineAt >= 0, 'the session annotation board is missing');
assert.ok(
drawAt > engineAt,
'the draw tool must follow the annotation board and receive it',
);
// The page session owns the tool; its rows answer through the store.
assert.match(
tools,
session,
/import \{ createSessionDrawTool \} from '\.\.\/annotations\/drawTool\.js';/,
);
assert.match(
session,
/sessionTool\(layerSession, 'draw', defer, \(\) =>\s*createSessionDrawTool\(\{\s*annotations,\s*navigation: styleManager,\s*publish: \(view\) => displayStore\.publishDraw\(view\),/,
);
// The tool holds a pointer claim, DOM listeners and a data source on the
// viewer, so the viewer's attachment ends it — not whoever last pressed the
// button; the session tool ends with the page.
+17 -2
View File
@@ -18,6 +18,11 @@ import {
} from '../renderGovernor.js';
import { attachSessionVoice } from './sessionVoice.js';
import { initNavaraImageryBoxTool } from '../ui/imageryBoxNavara.js';
import {
attachNavaraAnnotations,
attachNavaraDraw,
attachNavaraGeoImport,
} from './navaraTools.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
// Native mesh capability stays at the composition boundary.
@@ -461,6 +466,16 @@ export function attachViewerlessTools(
});
// The notice is this engine's: it goes when the engine does.
defer(() => styleManager._clearPersistentStatusNotice?.());
// The page session's annotation board, Draw and Import on this engine
// (src/app/navaraTools.js).
const tools = { engine, view: navaraViews.get(engine) };
const context = { operations, placeSearch, layerSession, styleManager };
const annotations = attachNavaraAnnotations(tools, context, {
signal,
defer,
});
attachNavaraDraw(tools, { ...context, annotations }, { signal, defer });
attachNavaraGeoImport(tools, context, { signal, defer });
// Voice on the engine (phase 3, Task 9): the W1 tools drive the engine;
// the Director uses the shared session; viewer-only tools declare availability.
const voiceCommands = attachSessionVoice({
@@ -474,10 +489,10 @@ export function attachViewerlessTools(
engine,
styleManager,
sceneDirector,
annotations: null,
annotations,
},
signal,
defer,
});
return { annotations: null, voiceCommands };
return { annotations, voiceCommands };
}
+21
View File
@@ -0,0 +1,21 @@
// The page session's annotation board, Draw and Import on the experimental
// Navara engine (src/app/sessionTools.js holds the tools themselves). Loaded
// with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it.
/**
* The annotation board drawn on this Navara scene. Returns the board, or
* null while the engine draws no annotations.
*/
export function attachNavaraAnnotations() {
return null;
}
/** UTILITIES ▸ Draw on this Navara scene; null while it has none. */
export function attachNavaraDraw() {
return null;
}
/** UTILITIES ▸ Import on this Navara scene; null while it has none. */
export function attachNavaraGeoImport() {
return null;
}
+126
View File
@@ -0,0 +1,126 @@
// The page session's tools that outlive a globe engine: the annotation board,
// UTILITIES ▸ Draw and UTILITIES ▸ Import. Each is created once per page
// session (destroyed with the page) and attaches its drawing to whichever
// engine the page shows: src/app/tools.js on a Cesium viewer,
// src/app/navaraTools.js on Navara.
import { createSessionAnnotations } from '../annotations/index.js';
import { createSessionDrawTool } from '../annotations/drawTool.js';
import { createGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
/**
* The annotation board: the page session's when there is one (created once,
* destroyed with the page), else this application's.
*/
export function sessionAnnotations({
layerSession,
placeSearch,
resolver,
defer,
}) {
const create = () => createSessionAnnotations({ placeSearch, resolver });
if (!layerSession) {
const annotations = create();
defer(() => destroyAnnotations(annotations));
return annotations;
}
if (!layerSession.annotations) {
const annotations = (layerSession.annotations = create());
layerSession.lifetime.signal.addEventListener(
'abort',
() => destroyAnnotations(annotations),
{ once: true },
);
}
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.
*/
export 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();
}
/**
* The Display panel's store and `connectDisplay(actions)`: the panel's
* controls answer through the store; returns the disconnect.
*/
export function displayControls() {
const displayStore = useDisplayStore(applicationPinia());
const connectDisplay = (actions) => {
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
};
return { displayStore, connectDisplay };
}
/**
* UTILITIES ▸ Draw: the same whiteboard, drawn by hand. The marks and the
* chosen shape belong to the page session; the tool claims the attached
* globe's pointer while a sketch is open, so an engine switch ends the
* sketch. A row's fly-to goes through the shell's navigation policy, as
* Import's does. Null on a page without the Draw controls.
*/
export function sessionDrawTool({
layerSession,
annotations,
styleManager,
defer,
}) {
const { displayStore, connectDisplay } = displayControls();
return sessionTool(layerSession, 'draw', defer, () =>
createSessionDrawTool({
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
}),
);
}
/**
* UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
* own data source. They live for the page session: an engine switch takes
* them off the globe, and the next globe reads them again. The drop
* listeners belong to the attached globe. Null on a page without the
* Import control.
*/
export function sessionGeoImport({
layerSession,
styleManager,
operations,
defer,
}) {
const { displayStore, connectDisplay } = displayControls();
return sessionTool(layerSession, 'geoImport', defer, () =>
createGeoFileImport({
navigation: styleManager,
ground: operations.surface?.groundFloor,
publish: (view) => displayStore.publishGeoImport(view),
connect: connectDisplay,
}),
);
}
+21 -90
View File
@@ -1,12 +1,12 @@
import { SceneDirector } from '../scenes/director.js';
import { createSessionAnnotations } from '../annotations/index.js';
import { createSessionDrawTool } from '../annotations/drawTool.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';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { attachSessionVoice } from './sessionVoice.js';
import {
sessionAnnotations,
sessionDrawTool,
sessionGeoImport,
} from './sessionTools.js';
import {
attachScenePart,
currentSceneOf,
@@ -23,60 +23,6 @@ import {
releaseContinuousRender,
} from '../renderGovernor.js';
/**
* The annotation board: the page session's when there is one (created once,
* destroyed with the page), else this application's.
*/
export function sessionAnnotations({
layerSession,
placeSearch,
resolver,
defer,
}) {
const create = () => createSessionAnnotations({ placeSearch, resolver });
if (!layerSession) {
const annotations = create();
defer(() => destroyAnnotations(annotations));
return annotations;
}
if (!layerSession.annotations) {
const annotations = (layerSession.annotations = create());
layerSession.lifetime.signal.addEventListener(
'abort',
() => destroyAnnotations(annotations),
{ once: true },
);
}
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();
}
/**
* Attach scene tools, rendering listeners and the application debug handle.
* The Director, the annotation board, Draw, Import, voice and the debug
@@ -305,39 +251,24 @@ function attachViewerTools(
});
annotations.attachScene({ viewer, tileset });
defer(() => annotations.detachScene());
const displayStore = useDisplayStore(applicationPinia());
// The Display panel's controls answer through the store; returns the
// disconnect.
const connectDisplay = (actions) => {
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
};
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. The marks and the
// chosen shape belong to the page session; the tool claims this viewer's
// pointer while a sketch is open, so an engine switch ends the sketch. A
// row's fly-to goes through the shell's navigation policy, as Import's does.
const drawTool = sessionTool(layerSession, 'draw', defer, () =>
createSessionDrawTool({
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
}),
);
// UTILITIES ▸ Draw on this viewer (src/app/sessionTools.js): the tool
// claims this viewer's pointer while a sketch is open.
const drawTool = sessionDrawTool({
layerSession,
annotations,
styleManager,
defer,
});
drawTool?.attachScene({ viewer, navigation: styleManager });
defer(() => drawTool?.detachScene());
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// 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,
}),
);
// UTILITIES ▸ Import on this viewer: the next Cesium viewer reads the
// session's files again. The drop listeners belong to this viewer.
const geoFileImport = sessionGeoImport({
layerSession,
styleManager,
operations,
defer,
});
geoFileImport?.attachScene({
viewer,
navigation: styleManager,
+12 -8
View File
@@ -328,16 +328,16 @@ test('the application composition wires the import and owns its teardown', () =>
new URL('../app/tools.js', import.meta.url),
'utf8',
);
const drawAt = tools.indexOf(
"const drawTool = sessionTool(layerSession, 'draw'",
);
const importAt = tools.indexOf(
"const geoFileImport = sessionTool(layerSession, 'geoImport', defer, () =>",
const session = fs.readFileSync(
new URL('../app/sessionTools.js', import.meta.url),
'utf8',
);
const drawAt = tools.indexOf('const drawTool = sessionDrawTool({');
const importAt = tools.indexOf('const geoFileImport = sessionGeoImport({');
assert.ok(importAt > drawAt && drawAt >= 0);
// Fly-to goes through the application shell's navigation policy.
assert.match(
tools.slice(importAt, importAt + 200),
tools.slice(importAt, importAt + 400),
/navigation: styleManager/,
);
// The session owns the files; this viewer only their drawing.
@@ -345,9 +345,13 @@ test('the application composition wires the import and owns its teardown', () =>
tools.indexOf('defer(() => geoFileImport?.detachScene());') > importAt,
);
// The list renders from the store and its controls answer through it.
const sessionAt = session.indexOf(
"sessionTool(layerSession, 'geoImport', defer, () =>",
);
assert.ok(sessionAt >= 0);
assert.match(
tools.slice(importAt, importAt + 400),
/publish: \(view\) => displayStore\.publishGeoImport\(view\),\s*connect: connectDisplay,/,
session.slice(sessionAt, sessionAt + 400),
/navigation: styleManager,[\s\S]*publish: \(view\) => displayStore\.publishGeoImport\(view\),\s*connect: connectDisplay,/,
);
const markup = fs.readFileSync(
new URL('./views/panels/GeoImport.vue', import.meta.url),