Files
gods-eye-view/src/annotations/drawTool.js
T
Sameh Khamis 73e789489b chore: drop exports nothing imports
Knip, configured with the application, package exports, scripts, QA
harnesses, workers, tools and tests as entry points, reports exports
that no module imports. Each was checked by hand against string-based
and namespace imports.

- Symbols that are still used inside their own module lose only the
  `export` keyword (176 constants and helpers).
- Unused re-exports are removed from barrel modules (flight, military,
  vessel, CCTV, directions, transit history, local ADS-B and street-level
  policy modules).
- Declarations that nothing uses at all are deleted, among them
  AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText,
  DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band
  constants.
- Default exports that nothing imports are removed. The ones in
  src/data/earthquakes.js, src/data/telegeographySubmarineCables.js,
  src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a
  layer instance at import time that nothing used; those instances are
  no longer created.

Every export of a package.json entry stays, as does TOMTOM_CREDIT,
which the traffic layer reads from the credits namespace it is given.
2026-10-22 19:00:40 -07:00

572 lines
20 KiB
JavaScript

/**
* Manual whiteboard drawing: the Cesium + DOM half.
*
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. 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 { createSegmentedControl } from '../ui/segmentedControl.js';
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 { msg, registerUnsavedWork, 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 COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
/**
* Wire the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({ viewer, annotations }) {
const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const labelInput = document.getElementById('draw-label-input');
const colorSelect = document.getElementById('draw-color-select');
const clearButton = document.getElementById('draw-clear');
const hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null;
let active = false;
let destroyed = false;
let session = null;
let shape = 'area';
let color = 'primary';
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, so teardown is one loop rather than a
// list that can fall out of step with the bindings below.
const domListeners = [];
const listen = (target, type, listener, options) => {
if (!target) return;
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
// The board lives only in memory: switching language (a reload) asks first
// while it holds a lasting mark or a shape is half drawn.
const unregisterUnsavedWork = registerUnsavedWork(() => {
if (destroyed) return null;
const lasting = (annotations.list?.() || []).some(
(mark) => mark && mark.ttlMs == null && !mark.expiring,
);
return lasting || session?.vertices?.length > 0
? msg('drawings on the map')
: null;
});
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 setHint = (text) => {
if (hint) hint.textContent = text;
};
// ---- 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;
}
const spec = finishSpec(session, { label: labelInput?.value || '', 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();
if (labelInput) labelInput.value = '';
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 || t === labelInput) 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,
* the page body, or the label field this tool owns.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || t === labelInput) 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;
// 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' && event.target !== labelInput) {
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;
toggle.classList.toggle('active', active);
toggle.setAttribute('aria-pressed', String(active));
modeRow?.classList.toggle('visible', active);
labelRow?.classList.toggle('visible', active);
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 just the keydown listener bound alongside this handler; the control
// listeners below outlive a session and come off in destroy().
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);
}
}
listen(toggle, 'click', () => setActive(!active));
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
const shapeControl = createSegmentedControl({
buttons: shapeButtons,
valueOf: (button) => button.dataset.shape,
listen,
onChange: (value) => {
shape = normalizeShape(value);
shapeControl.update(shape);
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
},
});
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
});
listen(clearButton, 'click', clearAll);
listen(labelInput, 'keydown', (event) => {
if (event.key === 'Enter' && session?.vertices?.length) {
event.preventDefault();
void finish();
}
});
setHint(drawHint(null));
const api = {
get active() {
return active;
},
get shape() {
return shape;
},
get session() {
return session;
},
setActive,
setShape(next) {
const btn = modeRow?.querySelector(
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
);
btn?.click();
},
/** 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;
unregisterUnsavedWork();
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');
toggle.classList.remove('active');
toggle.setAttribute('aria-pressed', 'false');
modeRow?.classList.remove('visible');
labelRow?.classList.remove('visible');
if (hint) hint.textContent = '';
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;
}