mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
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:
1 parent
05578c61b9
commit
111ba1626c
6 files changed
+239
-28
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user