Files
gods-eye-view/src/annotations/drawTool.js
T
Said Turksever 4ebb287d36 Merge main into the Utilities panel, rebuilt on the Vue panel rail
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.
2026-11-02 16:43:29 +00:00

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;
}