Keep the annotation board across engine switches and draw it again on Cesium

The marks belong to the page session; only their drawing belongs to the attached viewer. A switch takes the drawing off, keeps the marks, and the next Cesium attachment draws them again. Without a viewer the board refuses new annotations instead of failing in the resolver.
This commit is contained in:
German Khokhlov committed 2026-11-03 17:24:39 +04:00
1 parent 05578c61b9
commit 111ba1626c
6 files changed
+239 -28

No files matched your search

+71 -5
View File
@@ -130,10 +130,20 @@ export function normalizeTargetKey(target) {
return head || raw;
}
// While no globe draws the marks (an engine without annotation drawing, or
// between two engines) they stay on the board and this draws nothing.
const DETACHED_RENDERER = Object.freeze({
add() {},
update() {},
remove() {},
sync() {},
destroy() {},
});
export function createAnnotationEngine({
placeSearch = defaultGeospatial,
viewer,
renderer,
viewer = null,
renderer = DETACHED_RENDERER,
outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS,
resolveTarget = resolveAnnotationTarget,
}) {
@@ -410,14 +420,16 @@ export function createAnnotationEngine({
* @returns {Promise<{ok, drawn, failed, ids, results}>}
*/
async function annotate(requests, opts = {}) {
if (destroyed)
if (destroyed || !viewer)
return {
ok: false,
drawn: 0,
failed: 0,
ids: [],
results: [],
error: 'destroyed',
error: destroyed
? 'destroyed'
: 'Annotations are drawn on the Cesium globe only.',
};
const list = (Array.isArray(requests) ? requests : [requests]).map(
promoteStreetHighlight,
@@ -1630,7 +1642,7 @@ export function createAnnotationEngine({
/** Fly the camera to one mark; a line is framed whole, not by its start. */
function frame(id) {
const anno = annotations.get(id);
if (!anno) return false;
if (!anno || !viewer) return false;
if (anno.type === 'route' && anno.path?.length) {
const ring = anno.path.map((p) => [p.lon, p.lat]);
// Unwrap around the first vertex, as ringRange does, so a line across
@@ -1794,6 +1806,60 @@ export function createAnnotationEngine({
changeListeners.add(listener);
return () => changeListeners.delete(listener);
},
/**
* The globe leaves: take every mark off it and release its drawing. The
* marks stay on the board (list, count, Draw's rows) for attachRenderer.
*/
detachRenderer() {
if (destroyed || renderer === DETACHED_RENDERER) return;
// Work begun for this globe (outline lookups, a flight to a mark) ends
// with it; the marks already on the board stay.
abortPending();
dropQueuedOutlineUpgrades();
generation++;
const previous = renderer;
renderer = DETACHED_RENDERER;
viewer = null;
for (const anno of annotations.values()) {
anno.pendingOutline = false;
try {
previous.remove(anno);
} catch {
/* the globe is going; its drawing goes with it */
}
}
try {
previous.destroy();
} finally {
releaseContinuousRender('annotations');
}
},
/**
* Draw the board on a globe: `viewer` and the `renderer` built for it.
* Marks kept while no globe drew them appear again as they were.
*/
attachRenderer({ viewer: nextViewer, renderer: nextRenderer }) {
if (destroyed) return;
engine.detachRenderer();
viewer = nextViewer;
renderer = nextRenderer ?? DETACHED_RENDERER;
for (const anno of annotations.values()) {
try {
const completion = renderer.add(anno);
completion?.catch?.(() => {});
} catch {
rollbackRendererState(anno);
}
}
renderer.sync(annotations);
syncAnnotationHold();
ensureTicking();
governorRequestRender('annotations');
},
/** Whether a globe draws the marks now. */
get attached() {
return renderer !== DETACHED_RENDERER;
},
destroy() {
if (destroyed) return;
destroyed = true;
@@ -0,0 +1,95 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createAnnotationEngine } from './annotationEngine.js';
const BUILDING = {
lon: -97.74,
lat: 30.27,
ring: [
[-97.741, 30.269],
[-97.739, 30.269],
[-97.739, 30.271],
[-97.741, 30.269],
],
footprintKind: 'building',
label: 'Capitol',
};
function recordingRenderer(log, name) {
return {
add(anno) {
log.push(`${name} add ${anno.label}`);
},
update() {},
remove(anno) {
log.push(`${name} remove ${anno.label}`);
},
sync() {},
destroy() {
log.push(`${name} destroy`);
},
};
}
function withAnimationFrames(t) {
const saved = [
globalThis.requestAnimationFrame,
globalThis.cancelAnimationFrame,
];
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
t.after(() => {
[globalThis.requestAnimationFrame, globalThis.cancelAnimationFrame] = saved;
});
}
test('the marks survive a globe leaving and are drawn again on the next one', async (t) => {
withAnimationFrames(t);
const log = [];
const engine = createAnnotationEngine({
viewer: {},
renderer: recordingRenderer(log, 'cesium'),
resolveTarget: async () => ({ ...BUILDING }),
});
const result = await engine.annotate([
{ type: 'area', target: 'Capitol', label: 'Capitol' },
]);
assert.equal(result.drawn, 1);
assert.equal(engine.attached, true);
engine.detachRenderer();
assert.equal(engine.attached, false);
assert.equal(engine.count(), 1, 'the board keeps its mark');
assert.deepEqual(log.slice(-2), ['cesium remove Capitol', 'cesium destroy']);
// No globe draws annotations here: a new one is refused, nothing is lost.
const refused = await engine.annotate([{ type: 'pin', target: 'x' }]);
assert.equal(refused.ok, false);
assert.equal(engine.count(), 1);
assert.equal(engine.frame(engine.list()[0].id), false);
log.length = 0;
engine.attachRenderer({
viewer: {},
renderer: recordingRenderer(log, 'next'),
});
assert.deepEqual(log, ['next add Capitol']);
assert.equal(engine.attached, true);
assert.equal(engine.count(), 1);
engine.destroy();
assert.ok(log.includes('next destroy'));
});
test('detaching twice and attaching over an attached globe release each drawing once', (t) => {
withAnimationFrames(t);
const log = [];
const engine = createAnnotationEngine({
viewer: {},
renderer: recordingRenderer(log, 'a'),
});
engine.detachRenderer();
engine.detachRenderer();
engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'b') });
engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'c') });
assert.deepEqual(log, ['a destroy', 'b destroy']);
engine.destroy();
});
+3 -3
View File
@@ -21,14 +21,14 @@ 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 engineAt = tools.indexOf('const annotations = initAnnotations({');
const engineAt = tools.indexOf('const annotations = sessionAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(engineAt >= 0, 'the session annotation board is missing');
assert.ok(
drawAt > engineAt,
'initDrawTool must follow initAnnotations and receive its engine',
'initDrawTool must follow the annotation board and receive it',
);
assert.match(
tools,
+29 -11
View File
@@ -16,21 +16,39 @@ export function initAnnotations({
placeSearch,
resolver,
}) {
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
try {
tileset.enableCollision = true;
} catch {
/* older tileset */
}
}
const renderer = createHybridAnnotationRenderer(viewer);
const engine = createSessionAnnotations({ placeSearch, resolver });
engine.attachScene({ viewer, tileset });
return engine;
}
/**
* The page's annotation board, kept across globe engine switches: the marks
* belong to the page session, their drawing to the attached globe.
* `attachScene({ viewer, tileset })` draws the board on a Cesium viewer (an
* engine without one keeps the marks undrawn); `detachScene()` releases the
* drawing and keeps the marks.
*/
export function createSessionAnnotations({ placeSearch, resolver }) {
const engine = createAnnotationEngine({
viewer,
renderer,
placeSearch,
resolveTarget: resolver?.resolveAnnotationTarget,
});
engine.attachScene = ({ viewer = null, tileset = null } = {}) => {
if (!viewer) return engine.detachRenderer();
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
try {
tileset.enableCollision = true;
} catch {
/* older tileset */
}
}
engine.attachRenderer({
viewer,
renderer: createHybridAnnotationRenderer(viewer),
});
};
engine.detachScene = () => engine.detachRenderer();
window.__gevAnnotations = engine;
return engine;
}
+40 -8
View File
@@ -1,5 +1,5 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { createSessionAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
@@ -18,6 +18,38 @@ 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;
}
function destroyAnnotations(annotations) {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
}
/**
* Attach scene tools, rendering listeners and the application debug handle.
* Without a viewer (the experimental Navara engine) the viewer-bound tools
@@ -90,16 +122,16 @@ export function createApplicationTools({
}),
);
}
const annotations = initAnnotations({
viewer,
tileset,
// The annotation board belongs to the page session when there is one: an
// engine switch only takes its drawing off this viewer.
const annotations = sessionAnnotations({
layerSession,
placeSearch,
resolver: operations.annotationResolver,
defer,
});
defer(() => {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
annotations.attachScene({ viewer, tileset });
defer(() => annotations.detachScene());
const displayStore = useDisplayStore(applicationPinia());
// The Display panel's controls answer through the store; returns the
// disconnect.
+1 -1
View File
@@ -45,7 +45,7 @@ const session = createSessionDiagnostics(() => {
placeSearch: style?.placeSearch,
dataManager: components.data?.dataManager,
voice: layerSession.voice?.commands ?? components.tools?.voiceCommands,
annotations: components.tools?.annotations,
annotations: layerSession.annotations ?? components.tools?.annotations,
geoImport: window.__gevGeoImport,
drawTool: window.__gevDrawTool,
};