mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.
- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
item (handyman). LayerPanel renders the Directions rows into its list with
LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
which render what the draw tool publishes to the display store and call
its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
drawMarkList.js is gone. A row's fly-to is an immediate navigation like
Import's ('drawing' gets its Cockpit refusal), the name field keeps a
draft through v-field-value, and the colours are one radio group with a
single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
buttons.
- The catalogs take main's languages with this branch's strings, Arabic
included, and the tests follow the views to their new homes.
600 lines
21 KiB
JavaScript
600 lines
21 KiB
JavaScript
/**
|
|
* Manual whiteboard drawing: the Cesium + DOM half.
|
|
*
|
|
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
|
* shape (area, line or pin) and a colour, click the vertices on the real world,
|
|
* double-click or press Enter to finish, then name, recolour, hide, fly to or
|
|
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
|
|
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
|
|
* supplied and `manual: true`, so it renders with the whiteboard look,
|
|
* persists, de-dups, shows up in `.list()`, and clears with the board.
|
|
*
|
|
* Two ownership rules make it safe to share the scene with the layers:
|
|
*
|
|
* - While a session is open the tool HOLDS THE POINTER
|
|
* (`src/data/inputOwnership.js`), and every ambient selection handler yields,
|
|
* so a vertex clicked on top of an aircraft or a camera is a vertex and
|
|
* nothing else. It is one claim consulted by all of them, not a list of
|
|
* exceptions inside each.
|
|
* - Cesium's Viewer binds its OWN left click (select the entity under the
|
|
* pointer) and double click (track it) on the viewer's handler, outside every
|
|
* layer and therefore outside the shared claim. Both are borrowed for the
|
|
* session and given back on the way out, and `destroy()` gives back every
|
|
* listener, the Cesium handler, the preview data source and the window
|
|
* handle, so the tool can be turned off or the shell disposed without leaving
|
|
* anything behind.
|
|
*
|
|
* A vertex carries the height the click landed on — a roof, a hillside — and
|
|
* that height is what the live preview hangs on, so the rubber band follows the
|
|
* surface under the pointer instead of sinking to sea level. The world renderer
|
|
* drapes finished areas and routes onto the surface, so the height is dropped
|
|
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
|
|
*/
|
|
import * as Cesium from 'cesium';
|
|
import { pickWorldFromScreen } from './annotationResolver.js';
|
|
import {
|
|
claimPointer,
|
|
pointerOwner,
|
|
releasePointer,
|
|
} from '../data/inputOwnership.js';
|
|
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
|
import {
|
|
DRAW_SHAPES,
|
|
MAX_VERTICES,
|
|
addVertex,
|
|
createDrawSession,
|
|
drawHint,
|
|
finishReason,
|
|
finishSpec,
|
|
normalizeShape,
|
|
removeLastVertex,
|
|
} from './drawMode.js';
|
|
|
|
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
|
|
|
|
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
|
|
/** The id this tool claims the pointer under. */
|
|
const DRAW_POINTER_OWNER = 'draw';
|
|
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
|
|
|
const PREVIEW = DRAW_COLOR_CSS;
|
|
|
|
/**
|
|
* Run the Draw control. Returns a handle with a `destroy()` the application
|
|
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
|
|
* The tool hands what the controls show (`{ active, shape, color, hint,
|
|
* marks }`) to `publish`, which the view renders, and answers the controls
|
|
* through the actions it gives `connect`, which returns their disconnect.
|
|
* A row's fly-to is an immediate navigation through `navigation`, like
|
|
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
|
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
|
* @returns {{destroy: Function}|null}
|
|
*/
|
|
export function initDrawTool({
|
|
viewer,
|
|
annotations,
|
|
navigation = null,
|
|
publish = () => {},
|
|
connect = () => () => {},
|
|
}) {
|
|
// A page without the Draw controls (the Display controls view omitted) has
|
|
// no draw tool.
|
|
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
|
|
return null;
|
|
|
|
let active = false;
|
|
let destroyed = false;
|
|
let session = null;
|
|
let shape = 'area';
|
|
let color = 'primary';
|
|
let hint = '';
|
|
let handler = null;
|
|
let lease = null;
|
|
let savedDoubleClick = null;
|
|
let savedSingleClick = null;
|
|
let cursor = null; // last mouse position on the canvas, for the rubber band
|
|
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
|
|
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
|
|
// one of those must not write its outcome over the newer state.
|
|
let generation = 0;
|
|
const previewEntities = [];
|
|
// Every DOM listener this tool adds (the session's keydown), so teardown is
|
|
// one loop rather than a list that can fall out of step with the bindings.
|
|
const domListeners = [];
|
|
const listen = (target, type, listener, options) => {
|
|
target.addEventListener(type, listener, options);
|
|
domListeners.push([target, type, listener, options]);
|
|
};
|
|
|
|
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
|
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
|
// remove a source that had not been attached yet and leave the attachment to
|
|
// land afterwards — a preview data source nobody owns. Chain the removal onto
|
|
// the attachment instead of racing it.
|
|
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
|
|
() => null,
|
|
);
|
|
|
|
const present = () =>
|
|
publish({
|
|
active,
|
|
shape,
|
|
color,
|
|
hint,
|
|
marks: presentDrawMarks(annotations.list?.() || []),
|
|
});
|
|
// The hint is kept as the call that words it, so a language switch can
|
|
// word it again.
|
|
let hintText = () => '';
|
|
const setHint = (text) => {
|
|
hintText = text;
|
|
hint = text();
|
|
present();
|
|
};
|
|
const offLocale = onLocaleChange(() => {
|
|
if (!destroyed) setHint(hintText);
|
|
});
|
|
|
|
// ---- preview ---------------------------------------------------------
|
|
const vertexPositions = () =>
|
|
session.vertices.map((v) =>
|
|
Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0),
|
|
);
|
|
const previewLine = dataSource.entities.add({
|
|
show: false,
|
|
polyline: {
|
|
positions: new Cesium.CallbackProperty(() => {
|
|
if (!session) return [];
|
|
const pts = vertexPositions();
|
|
if (cursor && session.shape !== 'pin') pts.push(cursor);
|
|
if (session.shape === 'area' && pts.length >= 3) pts.push(pts[0]);
|
|
return pts;
|
|
}, false),
|
|
width: 3,
|
|
material: new Cesium.PolylineDashMaterialProperty({
|
|
color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.9),
|
|
dashLength: 16,
|
|
}),
|
|
depthFailMaterial: new Cesium.PolylineDashMaterialProperty({
|
|
color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.35),
|
|
dashLength: 16,
|
|
}),
|
|
clampToGround: false,
|
|
},
|
|
});
|
|
const syncPreview = () => {
|
|
if (destroyed) return;
|
|
previewEntities.forEach((e) => dataSource.entities.remove(e));
|
|
previewEntities.length = 0;
|
|
if (!session) {
|
|
previewLine.show = false;
|
|
setHint(() => drawHint(null));
|
|
viewer.scene.requestRender();
|
|
return;
|
|
}
|
|
const stroke = Cesium.Color.fromCssColorString(
|
|
PREVIEW[color] || PREVIEW.primary,
|
|
);
|
|
previewLine.polyline.material = new Cesium.PolylineDashMaterialProperty({
|
|
color: stroke.withAlpha(0.9),
|
|
dashLength: 16,
|
|
});
|
|
previewLine.show = session.shape !== 'pin';
|
|
for (const v of session.vertices) {
|
|
previewEntities.push(
|
|
dataSource.entities.add({
|
|
position: Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0),
|
|
point: {
|
|
pixelSize: 8,
|
|
color: stroke,
|
|
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
|
|
outlineWidth: 2,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
setHint(() => drawHint(session));
|
|
viewer.scene.requestRender();
|
|
};
|
|
|
|
// ---- vertices from clicks --------------------------------------------
|
|
const worldAt = (position) => {
|
|
const canvas = viewer.scene.canvas;
|
|
const w = canvas.clientWidth || canvas.width || 1;
|
|
const h = canvas.clientHeight || canvas.height || 1;
|
|
return pickWorldFromScreen(viewer, position.x / w, position.y / h);
|
|
};
|
|
const onClick = (event) => {
|
|
if (!session || destroyed) return;
|
|
const p = worldAt(event.position);
|
|
if (!p) return;
|
|
const { added, reason } = addVertex(session, p);
|
|
if (added) {
|
|
syncPreview();
|
|
return;
|
|
}
|
|
// A click that changed nothing still deserves an answer when the reason is
|
|
// a limit rather than the harmless tail of a double-click.
|
|
if (reason === 'full')
|
|
setHint(() =>
|
|
t(
|
|
'That shape already has {count} points — finish it or press Backspace.',
|
|
{ count: MAX_VERTICES },
|
|
),
|
|
);
|
|
else if (reason === 'invalid')
|
|
setHint(() => t('That point is off the globe — click on the world.'));
|
|
};
|
|
const onMove = (event) => {
|
|
if (!session || session.shape === 'pin' || destroyed) return;
|
|
const p = worldAt(event.endPosition);
|
|
cursor = p
|
|
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
|
|
: null;
|
|
viewer.scene.requestRender();
|
|
};
|
|
|
|
// ---- finish / cancel -------------------------------------------------
|
|
const finish = async () => {
|
|
if (destroyed || !session) return null;
|
|
const reason = finishReason(session);
|
|
if (reason !== 'ok') {
|
|
// 'too-few' is just "keep clicking" and the hint already says so;
|
|
// a degenerate or off-globe shape needs to be told why it was refused.
|
|
if (reason !== 'too-few') setHint(() => drawHint(session));
|
|
return null;
|
|
}
|
|
// Named afterwards, in the shape's row.
|
|
const spec = finishSpec(session, { label: '', color });
|
|
if (!spec) return null;
|
|
// Start a fresh session BEFORE awaiting, so a second Enter or the second
|
|
// half of a double-click cannot submit the same shape twice.
|
|
session = createDrawSession(shape);
|
|
cursor = null;
|
|
syncPreview();
|
|
const attempt = generation;
|
|
try {
|
|
const result = await annotations.annotate([spec], {
|
|
persist: true,
|
|
flyTo: false,
|
|
});
|
|
if (destroyed || attempt !== generation) return result;
|
|
if (result?.drawn === 0)
|
|
setHint(() => t('That shape could not be placed.'));
|
|
return result;
|
|
} catch (error) {
|
|
if (destroyed || attempt !== generation) return null;
|
|
setHint(() =>
|
|
t('Could not place the shape: {error}', {
|
|
error: error?.message || error,
|
|
}),
|
|
);
|
|
return null;
|
|
}
|
|
};
|
|
const cancel = () => {
|
|
if (!session) return;
|
|
generation += 1;
|
|
session = createDrawSession(shape);
|
|
cursor = null;
|
|
syncPreview();
|
|
};
|
|
/** Wipe the board: the shape in progress AND every mark already placed. */
|
|
const clearAll = () => {
|
|
generation += 1;
|
|
cursor = null;
|
|
if (session) session = createDrawSession(shape);
|
|
annotations.clear();
|
|
syncPreview();
|
|
if (!destroyed && !active) setHint(() => t('Board cleared.'));
|
|
};
|
|
|
|
// ---- keys: only while drawing, never while typing in another field ----
|
|
const typingElsewhere = (event) => {
|
|
const t = event.target;
|
|
if (!t) return false;
|
|
return (
|
|
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
|
|
);
|
|
};
|
|
/**
|
|
* Enter is the finish key, but on a focused control Enter means "press this
|
|
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
|
|
* not finish the shape — so the finish key is only taken from the canvas or
|
|
* the page body.
|
|
*/
|
|
const enterBelongsToDrawing = (event) => {
|
|
const t = event.target;
|
|
if (!t) return true;
|
|
if (
|
|
typeof t.closest === 'function' &&
|
|
t.closest('button, a, select, [role="button"], [role="radio"]')
|
|
)
|
|
return false;
|
|
return (
|
|
t === document.body || t === viewer.scene.canvas || t.tagName === 'CANVAS'
|
|
);
|
|
};
|
|
const onKey = (event) => {
|
|
if (!active || destroyed || typingElsewhere(event)) return;
|
|
// Keys on the shape list's own controls are the list's (Esc closes a
|
|
// colour picker there), not the drawing's.
|
|
if (event.target?.closest?.('#draw-mark-list')) return;
|
|
// Enter goes to finish() once a shape has been started; finish() owns the
|
|
// decision and says WHY it refused. Swallowing the key on a degenerate
|
|
// shape left the person pressing Enter at a silent panel. A focused button
|
|
// keeps its own Enter — see enterBelongsToDrawing.
|
|
if (event.key === 'Enter') {
|
|
if (session?.vertices?.length && enterBelongsToDrawing(event)) {
|
|
event.preventDefault();
|
|
void finish();
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
if (session?.vertices.length) cancel();
|
|
else setActive(false);
|
|
return;
|
|
}
|
|
if (event.key === 'Backspace') {
|
|
if (removeLastVertex(session)) {
|
|
event.preventDefault();
|
|
syncPreview();
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---- mode on / off -----------------------------------------------------
|
|
function setActive(next) {
|
|
if (destroyed || next === active) return active;
|
|
if (next) {
|
|
lease = claimPointer(DRAW_POINTER_OWNER);
|
|
if (!lease) {
|
|
// Somebody else is using the pointer — possibly an older instance of
|
|
// this same tool that has not finished tearing down. Say so instead of
|
|
// half-starting.
|
|
setHint(() =>
|
|
t('{owner} is using the pointer — close it first.', {
|
|
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
|
}),
|
|
);
|
|
return active;
|
|
}
|
|
}
|
|
active = next;
|
|
present();
|
|
document.body.classList.toggle('gev-drawing', active);
|
|
if (active) {
|
|
session = createDrawSession(shape);
|
|
bindSceneHandler();
|
|
syncPreview();
|
|
} else {
|
|
generation += 1;
|
|
session = null;
|
|
cursor = null;
|
|
releaseSceneHandler();
|
|
releasePointer(lease);
|
|
lease = null;
|
|
syncPreview();
|
|
}
|
|
return active;
|
|
}
|
|
|
|
function bindSceneHandler() {
|
|
if (handler) return;
|
|
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
|
handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
|
handler.setInputAction(() => {
|
|
void finish();
|
|
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
|
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
|
|
// single click picks an entity into `viewer.selectedEntity`, the double
|
|
// click tracks it. Neither goes through a layer, so neither can be fixed by
|
|
// the shared pointer claim — they have to be borrowed outright for the
|
|
// session and given back on the way out. Borrowing the single click is what
|
|
// stops a vertex placed on a contact from also selecting it.
|
|
const stock = viewer.screenSpaceEventHandler;
|
|
savedSingleClick =
|
|
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
|
|
savedDoubleClick =
|
|
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
|
null;
|
|
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
|
listen(document, 'keydown', onKey, true);
|
|
}
|
|
|
|
function releaseSceneHandler() {
|
|
if (handler) {
|
|
handler.destroy();
|
|
handler = null;
|
|
}
|
|
if (savedSingleClick) {
|
|
viewer.screenSpaceEventHandler.setInputAction(
|
|
savedSingleClick,
|
|
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
|
);
|
|
savedSingleClick = null;
|
|
}
|
|
if (savedDoubleClick) {
|
|
viewer.screenSpaceEventHandler.setInputAction(
|
|
savedDoubleClick,
|
|
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
|
);
|
|
savedDoubleClick = null;
|
|
}
|
|
// Drop the keydown listener bound alongside this handler.
|
|
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
|
|
const [target, type, listener, options] = domListeners[i];
|
|
if (listener !== onKey) continue;
|
|
target.removeEventListener(type, listener, options);
|
|
domListeners.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
|
|
* and the current orbit, follow or track lets go first.
|
|
*/
|
|
function flyToMark(id) {
|
|
if (typeof navigation?.runImmediateNavigation === 'function')
|
|
return (
|
|
navigation.runImmediateNavigation('drawing', () =>
|
|
annotations.frame?.(id),
|
|
) !== false
|
|
);
|
|
return annotations.frame?.(id) ?? false;
|
|
}
|
|
|
|
/** Draw `next` from now on; an open shape starts over in it. */
|
|
function chooseShape(next) {
|
|
shape = normalizeShape(next);
|
|
present();
|
|
if (active) {
|
|
generation += 1;
|
|
session = createDrawSession(shape);
|
|
cursor = null;
|
|
syncPreview();
|
|
}
|
|
}
|
|
const disconnect = connect({
|
|
toggleDraw: () => setActive(!active),
|
|
setDrawShape: chooseShape,
|
|
setDrawColor: (value) => {
|
|
color = DRAW_COLORS.includes(value) ? value : 'primary';
|
|
present();
|
|
syncPreview();
|
|
},
|
|
clearDraw: clearAll,
|
|
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
|
|
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
|
|
toggleDrawMark: (id) => {
|
|
const mark = annotations.list?.().find((entry) => entry.id === id);
|
|
if (mark) annotations.setHidden?.(id, !mark.hidden);
|
|
},
|
|
flyToDrawMark: flyToMark,
|
|
removeDrawMark: (id) => annotations.remove?.(id),
|
|
});
|
|
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
|
// here, by voice or by a clear.
|
|
const offBoard =
|
|
annotations.onChange?.(() => {
|
|
if (!destroyed) present();
|
|
}) || (() => {});
|
|
setHint(() => drawHint(null));
|
|
|
|
const api = {
|
|
get active() {
|
|
return active;
|
|
},
|
|
get shape() {
|
|
return shape;
|
|
},
|
|
get session() {
|
|
return session;
|
|
},
|
|
setActive,
|
|
setShape: chooseShape,
|
|
/** The hand-drawn marks the list shows, as its rows. */
|
|
marks: () => presentDrawMarks(annotations.list?.() || []),
|
|
setColor(next) {
|
|
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
|
present();
|
|
syncPreview();
|
|
},
|
|
flyToMark,
|
|
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
|
addVertex(lon, lat, height = 0) {
|
|
if (!session) return false;
|
|
const r = addVertex(session, { lon, lat, height });
|
|
if (r.added) syncPreview();
|
|
return r.added;
|
|
},
|
|
finish,
|
|
cancel,
|
|
clearAll,
|
|
shapes: DRAW_SHAPES,
|
|
/** What this tool currently holds — for teardown and leak assertions. */
|
|
diagnostics() {
|
|
return {
|
|
active,
|
|
destroyed,
|
|
sceneHandler: Boolean(handler),
|
|
domListeners: domListeners.length,
|
|
previewDataSources: countPreviewDataSources(viewer),
|
|
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
|
|
pointerOwner: pointerOwner(),
|
|
// The viewer's OWN click actions. While a session is open both are
|
|
// borrowed (absent here); after it closes both are back. A harness can
|
|
// compare the restored functions by identity with what it captured
|
|
// before, which is the only way to prove they were given back rather
|
|
// than replaced.
|
|
stockSingleClick: Boolean(
|
|
viewer.screenSpaceEventHandler?.getInputAction?.(
|
|
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
|
),
|
|
),
|
|
stockDoubleClick: Boolean(
|
|
viewer.screenSpaceEventHandler?.getInputAction?.(
|
|
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
|
),
|
|
),
|
|
};
|
|
},
|
|
/** Resolves once every deferred teardown step has run. */
|
|
whenSettled() {
|
|
return attaching;
|
|
},
|
|
destroy() {
|
|
if (destroyed) return attaching;
|
|
// Supersede any finish still in flight before anything is torn down.
|
|
generation += 1;
|
|
if (active) setActive(false);
|
|
destroyed = true;
|
|
offLocale();
|
|
offBoard();
|
|
disconnect();
|
|
releaseSceneHandler();
|
|
releasePointer(lease);
|
|
lease = null;
|
|
for (const [target, type, listener, options] of domListeners.splice(0)) {
|
|
target.removeEventListener(type, listener, options);
|
|
}
|
|
previewEntities.length = 0;
|
|
dataSource.entities.removeAll();
|
|
// Wait for the pending add() before removing: a destroy() in the same
|
|
// tick as init would otherwise remove nothing and let the attachment land
|
|
// behind it.
|
|
attaching = attaching.then(() => {
|
|
try {
|
|
viewer.dataSources.remove(dataSource, true);
|
|
} catch {
|
|
/* viewer already disposed — nothing to detach from */
|
|
}
|
|
});
|
|
document.body.classList.remove('gev-drawing');
|
|
hint = '';
|
|
present();
|
|
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
|
|
return attaching;
|
|
},
|
|
};
|
|
window.__gevDrawTool = api;
|
|
return api;
|
|
}
|
|
|
|
/** How many draw previews are attached — more than one means a leaked tool. */
|
|
function countPreviewDataSources(viewer) {
|
|
const sources = viewer?.dataSources;
|
|
if (!sources) return 0;
|
|
let found = 0;
|
|
for (let index = 0; index < sources.length; index += 1) {
|
|
if (sources.get(index)?.name === PREVIEW_DATA_SOURCE_NAME) found += 1;
|
|
}
|
|
return found;
|
|
}
|