diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d9849ed78..016563f0b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/annotations/drawTool.test.mjs b/src/annotations/drawTool.test.mjs index a1371a770..85e0ccc1d 100644 --- a/src/annotations/drawTool.test.mjs +++ b/src/annotations/drawTool.test.mjs @@ -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. diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 123f854f1..5f52a2358 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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 }; } diff --git a/src/app/navaraTools.js b/src/app/navaraTools.js new file mode 100644 index 000000000..2ba97f3d8 --- /dev/null +++ b/src/app/navaraTools.js @@ -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; +} diff --git a/src/app/sessionTools.js b/src/app/sessionTools.js new file mode 100644 index 000000000..38fe3ddb9 --- /dev/null +++ b/src/app/sessionTools.js @@ -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, + }), + ); +} diff --git a/src/app/tools.js b/src/app/tools.js index 604359638..5dc37bb41 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -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, diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 222db17f9..24efac19a 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -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),