feat: draw on the world by hand (DISPLAY ▸ Draw)

Pick Area, Line or Pin, click the vertices on the real world, double-click
or press Enter to finish, label and colour it. A drawn 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, exports to GeoJSON and clears with the board. No geocoder, Places or
Overpass call is made for a drawn shape.

- drawMode.js: pure session, minimum vertices, double-click de-dup, length
  and area, hint text, isDrawModeActive(); unit-tested
- drawTool.js: Cesium + DOM half; vertices world-anchored through the
  resolver's pickWorldFromScreen; dashed preview; stock double-click
  removed for the session and restored on exit; window.__gevDrawTool seam
- annotationEngine: manual specs resolve without any fetch; a drawn line is
  a route with mode manual, labelled with its length, never a travel time
- trackingClickGesture: yields while a draw session is open, so a vertex
  over a contact is a vertex, not a selection
- index.html / style.css: the Draw group in the DISPLAY panel
- docs: CURRENT-STATE, CHANGELOG, README
This commit is contained in:
Clint Borle 2026-09-09 10:59:48 -07:00
parent 759652207f
commit 4a444a7a26
13 changed files with 693 additions and 3 deletions

View File

@ -5,6 +5,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
### Added
- DISPLAY ▸ Draw: draw on the world by hand. Pick Area, Line or Pin, click the
vertices, double-click or press Enter to finish, label and colour it. Drawn
shapes go through the same annotation engine as spoken ones, so they render
with the whiteboard look, persist, export to GeoJSON and clear with the board.
### Fixed
- Mapped-site outages show their scheduled retry countdown and distinguish

View File

@ -241,6 +241,8 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod
**🖊️ Annotate it** — a whiteboard over the real world:
> 🗣️ *"Outline the state of Texas."* · *"Annotate the Texas State Capitol and its grounds"* — it draws the **actual enclosing boundary**, not a circle. · *"How far is the Eiffel Tower from the Louvre?"* — a connector arrow appears and it speaks the distance. Everything persists until you say *"clear the map."*
**✍️ Or draw it yourself** — DISPLAY ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence and export, no microphone needed.
![Zilker Park and Lady Bird Lake drawing onto the 3D city as persistent vector annotations, by voice](docs/media/01-voice-annotate-zilker.gif)
![A spoken distance measurement spanning an airport, inspected from orbit](docs/media/04-airport-distance.gif)

View File

@ -2218,6 +2218,15 @@ silently demoting every later lookup for the session.
- Console/dev API: `window.__gevAnnotations.tour()`, `.demo()`, `.annotate()`, `.clear()`, `.count()`, and `.list()` are the deterministic no-mic test surface.
- Current known resolver gap: mall/lifestyle districts such as "The Domain, Austin" can prefer a named building over the broader retail envelope. Product decision is that districts should become envelope + key buildings, but the scoring change still needs a careful multi-case validation pass.
### Manual Whiteboard Drawing (September 2026)
- DISPLAY ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, double-click or press Enter to finish, type an optional label and pick a colour first. Backspace removes the last vertex; Esc cancels the shape in progress, a second Esc leaves draw mode.
- Runtime entry: `src/main.js` calls `initDrawTool({ viewer, annotations })` immediately after `initAnnotations`; the handle is exposed as `window.__gevDrawTool` (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`) as the no-mouse test seam.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()` and the GeoJSON interchange, and clear with `clear_annotations` / `.clear()`.
- Vertices are world-anchored through `pickWorldFromScreen` (exported from the resolver), the same depth-aware pick cascade the voice screen fallback uses, so a vertex on a roof lands on the roof.
- Click ownership: while a session is open `isDrawModeActive()` (`src/annotations/drawMode.js`) is true and `bindTrackingClickGesture` returns before `onClick`, so a vertex over an aircraft, military contact or camera is a vertex, not a selection. The viewer's stock LEFT_DOUBLE_CLICK action is removed for the session and restored on exit.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, length/area, hint text). Regression surface: `src/annotations/drawMode.test.mjs`, `src/annotations/drawTool.test.mjs`.
### 3D Aircraft + Tracking (June 2026)
- **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load.

View File

@ -444,6 +444,31 @@
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
<span class="pp-label">Draw</span>
</button>
<div class="pp-slider-row" id="draw-mode-row">
<span class="pp-slider-mini-label">Shape</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw">
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true">Area</button>
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false">Line</button>
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false">Pin</button>
</div>
</div>
<div class="pp-slider-row" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape">
<option value="primary">Primary</option>
<option value="amber">Amber</option>
<option value="cyan">Cyan</option>
<option value="green">Green</option>
<option value="red">Red</option>
</select>
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>

View File

@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
import { isRateLimitedOutcome, resolveAnnotationTarget } from './annotationResolver.js';
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
@ -383,6 +383,9 @@ export function createAnnotationEngine({
async function resolveSpec(spec, signal) {
const type = normalizeType(spec?.type);
// MANUAL geometry (drawTool.js): the person clicked the vertices, so there is
// nothing to resolve. Same record shapes as a resolved spec, source 'manual'.
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
if (type === 'route') {
const points = Array.isArray(spec.points) ? spec.points : [];
if (points.length < 2) throw new Error('a route needs at least 2 waypoints');
@ -1073,6 +1076,49 @@ function round5(n) {
return Number.isFinite(n) ? Math.round(n * 1e5) / 1e5 : null;
}
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
function isManualSpec(spec, type) {
if (!spec || spec.manual !== true) return false;
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude));
}
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
function manualPairs(list) {
return (Array.isArray(list) ? list : [])
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)]))
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
}
/**
* Resolve a hand-drawn spec without any fetch. An area keeps its ring as a real
* (not synthesized) outline so it drapes solid; a line is a `route` with mode
* 'manual', its length as distance and no travel time; a pin is a point.
*/
function resolveManualSpec(spec, type, viewer) {
if (type === 'route') {
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 }));
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
let distanceM = 0;
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]);
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false };
}
if (type === 'area') {
const ring = manualPairs(spec.ring);
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points');
const lon = ring.reduce((a, p) => a + p[0], 0) / ring.length;
const lat = ring.reduce((a, p) => a + p[1], 0) / ring.length;
return {
lon, lat, height: sampleGroundHeight(viewer, lon, lat),
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual',
};
}
const lon = Number(spec.longitude);
const lat = Number(spec.latitude);
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' };
}
function normalizeMode(m) {
const t = String(m || '').toLowerCase();
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
@ -1123,6 +1169,7 @@ function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
if (!dist) return baseLabel;
const min = Number.isFinite(durS) ? Math.max(1, Math.round(durS / 60)) : null;
const word = mode === 'car' ? 'drive' : mode === 'bike' ? 'ride' : 'walk';
if (mode === 'manual') return baseLabel ? `${baseLabel}${dist}` : dist;
// Fallback = routing was unavailable, so we drew a straight line: label it as a
// direct line with no travel time (never claim an "X min walk" we didn't compute).
let metrics;

View File

@ -1847,7 +1847,7 @@ export async function placesNearViewRecovery(viewer, query, geocoded = null, sig
* fallback: the agent indicates a spot in the viewport screenshot when it can't
* name the place, and we anchor the mark to the actual world point under it.
*/
function pickWorldFromScreen(viewer, nx, ny) {
export function pickWorldFromScreen(viewer, nx, ny) {
const scene = viewer?.scene;
if (!scene) return null;
const canvas = scene.canvas;
@ -1889,7 +1889,7 @@ function pickWorldFromScreen(viewer, nx, ny) {
* the Google 3D tiles, so we try to clamp onto the photoreal tile surface; if
* the tiles for that spot aren't loaded we fall back to the ellipsoid (0).
*/
function sampleGroundHeight(viewer, lon, lat) {
export function sampleGroundHeight(viewer, lon, lat) {
const scene = viewer?.scene;
if (!scene) return 0;
try {

169
src/annotations/drawMode.js Normal file
View File

@ -0,0 +1,169 @@
/**
* Manual whiteboard drawing: the pure half.
*
* The voice whiteboard resolves NAMES to geometry. This module is for the other
* way in: a person clicks the vertices themselves. It holds a draw session (the
* shape being drawn and its vertices), decides when a shape is finishable, and
* turns a finished session into the SAME annotation spec the engine already
* accepts (`type: area | route | pin`, geometry supplied, `manual: true`), so a
* hand-drawn mark renders, persists, de-dups, exports to GeoJSON, and clears
* exactly like a spoken one.
*
* No Cesium, no DOM importable under `node --test`. The Cesium/DOM half is
* `drawTool.js`.
*/
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
export const MIN_VERTEX_SEPARATION_M = 0.5;
let drawModeActive = false;
/** Whether a manual draw session owns scene clicks right now (read by click gestures). */
export function isDrawModeActive() {
return drawModeActive;
}
/** @param {boolean} active */
export function setDrawModeActive(active) {
drawModeActive = Boolean(active);
return drawModeActive;
}
/** @param {string} shape @returns {'area'|'line'|'pin'} */
export function normalizeShape(shape) {
const s = String(shape || '').toLowerCase();
if (s === 'line' || s === 'path' || s === 'route') return 'line';
if (s === 'pin' || s === 'point' || s === 'marker') return 'pin';
return 'area';
}
/** @param {string} [shape] @returns {{shape: 'area'|'line'|'pin', vertices: Array<{lon:number, lat:number, height?:number}>}} */
export function createDrawSession(shape = 'area') {
return { shape: normalizeShape(shape), vertices: [] };
}
/** Great-circle distance in metres between two {lon, lat} points. */
export function greatCircleM(a, b) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const h = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
}
/**
* Add a vertex. A vertex that is not a finite lon/lat is refused; one within
* MIN_VERTEX_SEPARATION_M of the previous vertex is treated as the same click
* (the second half of a double-click) and refused as a duplicate. A pin holds
* exactly one vertex: a later click moves it.
* @returns {{added: boolean, reason?: 'invalid'|'duplicate'}}
*/
export function addVertex(session, vertex, { minSeparationM = MIN_VERTEX_SEPARATION_M } = {}) {
if (!session || !vertex || !Number.isFinite(vertex.lon) || !Number.isFinite(vertex.lat)) {
return { added: false, reason: 'invalid' };
}
const v = { lon: vertex.lon, lat: vertex.lat, height: Number.isFinite(vertex.height) ? vertex.height : 0 };
if (session.shape === 'pin') {
session.vertices = [v];
return { added: true };
}
const last = session.vertices[session.vertices.length - 1];
if (last && greatCircleM(last, v) < minSeparationM) return { added: false, reason: 'duplicate' };
session.vertices.push(v);
return { added: true };
}
/** Remove the last vertex. @returns {boolean} whether one was removed */
export function removeLastVertex(session) {
if (!session?.vertices?.length) return false;
session.vertices.pop();
return true;
}
/** @returns {boolean} whether the session has enough vertices to become an annotation */
export function canFinish(session) {
if (!session) return false;
return session.vertices.length >= (MIN_VERTICES[session.shape] || 1);
}
/** Length of an open path in metres. */
export function pathLengthM(vertices) {
let m = 0;
for (let i = 1; i < (vertices?.length || 0); i += 1) m += greatCircleM(vertices[i - 1], vertices[i]);
return m;
}
/** Planar shoelace area of a ring in square metres (local metre grid; fine at whiteboard scale). */
export function ringAreaM2(vertices) {
if (!vertices || vertices.length < 3) return 0;
const lat0 = vertices.reduce((s, v) => s + v.lat, 0) / vertices.length;
const kx = 111320 * Math.cos((lat0 * Math.PI) / 180);
const ky = 111320;
let twice = 0;
for (let i = 0; i < vertices.length; i += 1) {
const a = vertices[i];
const b = vertices[(i + 1) % vertices.length];
twice += (a.lon * kx) * (b.lat * ky) - (b.lon * kx) * (a.lat * ky);
}
return Math.abs(twice) / 2;
}
/** Vertex-average centroid of a ring, {lon, lat}. */
export function ringCentroid(vertices) {
if (!vertices?.length) return null;
return {
lon: vertices.reduce((s, v) => s + v.lon, 0) / vertices.length,
lat: vertices.reduce((s, v) => s + v.lat, 0) / vertices.length,
};
}
/** Distance or area, formatted for a label suffix. */
export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
return m >= 1000 ? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km` : `${Math.round(m)} m`;
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
return `${Math.round(m2)}`;
}
return '';
}
/**
* The annotation spec for a finished session, in the shape `annotationEngine.annotate()`
* takes. Null when the session cannot finish. Geometry is supplied outright and
* `manual: true` tells the engine to skip name resolution.
* @param {object} session
* @param {{label?: string, color?: string}} [opts]
*/
export function finishSpec(session, { label = '', color = 'primary' } = {}) {
if (!canFinish(session)) return null;
const text = String(label || '').trim();
const pts = session.vertices.map((v) => [v.lon, v.lat]);
if (session.shape === 'area') {
return { type: 'area', manual: true, ring: pts, label: text || null, color };
}
if (session.shape === 'line') {
return { type: 'route', manual: true, path: pts, label: text || null, color };
}
const [lon, lat] = pts[0];
return { type: 'pin', manual: true, latitude: lat, longitude: lon, label: text || null, color };
}
/** One line of guidance for the person drawing, by state. */
export function drawHint(session) {
if (!session) return 'Pick a shape, then click the map.';
const n = session.vertices.length;
if (session.shape === 'pin') return n ? 'Enter to place the pin, Esc to cancel.' : 'Click where the pin goes.';
const need = MIN_VERTICES[session.shape] - n;
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.`;
}

View File

@ -0,0 +1,105 @@
// Pure tests for the manual draw session. Run with: npm test (node --test). No Cesium, no DOM.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
createDrawSession,
addVertex,
removeLastVertex,
canFinish,
finishSpec,
normalizeShape,
pathLengthM,
ringAreaM2,
ringCentroid,
formatMeasure,
drawHint,
isDrawModeActive,
setDrawModeActive,
MIN_VERTICES,
} from './drawMode.js';
test('shapes normalize to area, line or pin, and unknown words fall back to area', () => {
assert.equal(normalizeShape('route'), 'line');
assert.equal(normalizeShape('marker'), 'pin');
assert.equal(normalizeShape('polygon'), 'area');
assert.equal(normalizeShape(undefined), 'area');
});
test('an area needs three vertices, a line two, a pin one', () => {
const area = createDrawSession('area');
addVertex(area, { lon: -97.74, lat: 30.27 });
addVertex(area, { lon: -97.73, lat: 30.27 });
assert.equal(canFinish(area), false);
addVertex(area, { lon: -97.73, lat: 30.28 });
assert.equal(canFinish(area), true);
assert.equal(MIN_VERTICES.line, 2);
assert.equal(MIN_VERTICES.pin, 1);
});
test('a second click within half a metre is the tail of a double-click, not a vertex', () => {
const line = createDrawSession('line');
assert.deepEqual(addVertex(line, { lon: 0, lat: 0 }), { added: true });
assert.deepEqual(addVertex(line, { lon: 0.000001, lat: 0 }), { added: false, reason: 'duplicate' });
assert.deepEqual(addVertex(line, { lon: 'x', lat: 0 }), { added: false, reason: 'invalid' });
assert.equal(line.vertices.length, 1);
});
test('a pin keeps exactly one vertex: a later click moves it', () => {
const pin = createDrawSession('pin');
addVertex(pin, { lon: 1, lat: 1 });
addVertex(pin, { lon: 2, lat: 2 });
assert.equal(pin.vertices.length, 1);
assert.deepEqual(pin.vertices[0], { lon: 2, lat: 2, height: 0 });
});
test('backspace removes the last vertex and reports whether it did', () => {
const s = createDrawSession('line');
assert.equal(removeLastVertex(s), false);
addVertex(s, { lon: 0, lat: 0 });
assert.equal(removeLastVertex(s), true);
assert.equal(s.vertices.length, 0);
});
test('finishSpec yields the engine spec shapes with manual geometry, or null when unfinished', () => {
const area = createDrawSession('area');
assert.equal(finishSpec(area), null);
[[-97.74, 30.27], [-97.73, 30.27], [-97.73, 30.28]].forEach(([lon, lat]) => addVertex(area, { lon, lat }));
assert.deepEqual(finishSpec(area, { label: ' Zilker ', color: 'amber' }), {
type: 'area', manual: true, ring: [[-97.74, 30.27], [-97.73, 30.27], [-97.73, 30.28]], label: 'Zilker', color: 'amber',
});
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0.01, lat: 0 });
assert.deepEqual(finishSpec(line), { type: 'route', manual: true, path: [[0, 0], [0.01, 0]], label: null, color: 'primary' });
const pin = createDrawSession('pin');
addVertex(pin, { lon: 151.2, lat: -33.9 });
assert.deepEqual(finishSpec(pin, { label: 'A shed' }), { type: 'pin', manual: true, latitude: -33.9, longitude: 151.2, label: 'A shed', color: 'primary' });
});
test('length and area come out in metres on a local grid', () => {
const km = pathLengthM([{ lon: 0, lat: 0 }, { lon: 0, lat: 0.009 }]);
assert.ok(km > 990 && km < 1010, `1 km of latitude, got ${km}`);
const square = [{ lon: 0, lat: 0 }, { lon: 0.001, lat: 0 }, { lon: 0.001, lat: 0.001 }, { lon: 0, lat: 0.001 }];
const m2 = ringAreaM2(square);
assert.ok(m2 > 12000 && m2 < 12800, `~111 m square, got ${m2}`);
assert.deepEqual(ringCentroid(square), { lon: 0.0005, lat: 0.0005 });
});
test('the measure and the hint follow the shape and the vertex count', () => {
const area = createDrawSession('area');
assert.equal(drawHint(area), 'Click 3 more points.');
[{ lon: 0, lat: 0 }, { lon: 0.01, lat: 0 }, { lon: 0.01, lat: 0.01 }].forEach((v) => addVertex(area, v));
assert.match(formatMeasure(area), /ha$|km²$|m²$/);
assert.match(drawHint(area), /double-click or Enter to finish/);
const pin = createDrawSession('pin');
assert.equal(drawHint(pin), 'Click where the pin goes.');
assert.equal(drawHint(null), 'Pick a shape, then click the map.');
});
test('the draw-mode flag is a plain module switch the click gesture can read', () => {
assert.equal(isDrawModeActive(), false);
assert.equal(setDrawModeActive(true), true);
assert.equal(isDrawModeActive(), true);
setDrawModeActive(false);
assert.equal(isDrawModeActive(), false);
});

235
src/annotations/drawTool.js Normal file
View File

@ -0,0 +1,235 @@
/**
* 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()` / GeoJSON export, and clears with the board.
*
* Clicks are world-anchored through the same depth-aware pick cascade the
* resolver's screen fallback uses (`pickWorldFromScreen`), so a vertex on a
* roof lands on the roof. While a session is open, `isDrawModeActive()` is
* true and the tracking click gesture yields, so clicking a vertex over an
* aircraft draws a vertex instead of tracking the plane.
*/
import * as Cesium from 'cesium';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
DRAW_SHAPES,
addVertex,
canFinish,
createDrawSession,
drawHint,
finishSpec,
normalizeShape,
removeLastVertex,
setDrawModeActive,
} from './drawMode.js';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff', amber: '#ffb547', cyan: '#39d0ff', green: '#5dff9f', red: '#ff6b6b',
};
/**
* Wire the Draw control. Idempotent per viewer; returns a small handle for tests
* and the console (`window.__gevDrawTool`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function}}} deps
*/
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 hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null;
let active = false;
let session = null;
let shape = 'area';
let color = 'primary';
let handler = null;
let savedDoubleClick = null;
let cursor = null; // last mouse position on the canvas, for the rubber band
const previewEntities = [];
const dataSource = new Cesium.CustomDataSource('gev-draw-preview');
viewer.dataSources.add(dataSource);
// ---- 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 = () => {
previewEntities.forEach((e) => dataSource.entities.remove(e));
previewEntities.length = 0;
if (!session) { previewLine.show = false; 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 },
}));
}
if (hint) hint.textContent = 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) return;
const p = worldAt(event.position);
if (!p) return;
const { added } = addVertex(session, p);
if (added) syncPreview();
};
const onMove = (event) => {
if (!session || session.shape === 'pin') 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 (!session || !canFinish(session)) return null;
const spec = finishSpec(session, { label: labelInput?.value || '', color });
const finished = session;
session = createDrawSession(shape);
cursor = null;
syncPreview();
if (labelInput) labelInput.value = '';
try {
const result = await annotations.annotate([spec], { persist: true, flyTo: false });
if (hint && result?.drawn === 0) hint.textContent = 'That shape could not be placed.';
return result;
} catch (error) {
if (hint) hint.textContent = `Could not place the shape: ${error?.message || error}`;
return null;
} finally {
void finished;
}
};
const cancel = () => {
if (!session) return;
session = createDrawSession(shape);
cursor = null;
syncPreview();
};
// ---- 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;
};
const onKey = (event) => {
if (!active || typingElsewhere(event)) return;
if (event.key === 'Enter') { if (canFinish(session)) { 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 (next === active) return;
active = next;
setDrawModeActive(active);
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);
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);
// The viewer's stock double-click tracks whatever entity is under the
// pointer; while a shape is being drawn a double-click finishes it.
const stock = viewer.screenSpaceEventHandler;
savedDoubleClick = stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || null;
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
document.addEventListener('keydown', onKey, true);
syncPreview();
} else {
session = null;
cursor = null;
handler?.destroy();
handler = null;
if (savedDoubleClick) viewer.screenSpaceEventHandler.setInputAction(savedDoubleClick, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
savedDoubleClick = null;
document.removeEventListener('keydown', onKey, true);
syncPreview();
if (hint) hint.textContent = drawHint(null);
}
}
toggle.addEventListener('click', () => setActive(!active));
modeRow?.querySelectorAll('.pp-mode-btn[data-shape]').forEach((btn) => {
btn.addEventListener('click', () => {
shape = normalizeShape(btn.dataset.shape);
modeRow.querySelectorAll('.pp-mode-btn[data-shape]').forEach((b) => {
const on = b === btn;
b.classList.toggle('active', on);
b.setAttribute('aria-checked', String(on));
});
if (active) { session = createDrawSession(shape); cursor = null; syncPreview(); }
});
});
colorSelect?.addEventListener('change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
});
labelInput?.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && canFinish(session)) { event.preventDefault(); void finish(); }
});
if (hint) hint.textContent = 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,
shapes: DRAW_SHAPES,
};
window.__gevDrawTool = api;
return api;
}

View File

@ -0,0 +1,55 @@
// Source-contract tests for the manual draw tool's wiring: the seams other
// modules rely on must stay where they are. Pure file reads, no Cesium, no DOM.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
test('main wires the draw tool right after the annotation engine it draws into', () => {
const main = read('src/main.js');
const engineAt = main.indexOf('const annotations = initAnnotations({ viewer, tileset });');
const drawAt = main.indexOf('initDrawTool({ viewer, annotations });');
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(drawAt > engineAt, 'initDrawTool must follow initAnnotations and receive its engine');
assert.match(main, /import \{ initDrawTool \} from '\.\/annotations\/drawTool\.js';/);
});
test('the tracking click gesture yields to an open draw session before it reaches onClick', () => {
const gesture = read('src/data/trackingClickGesture.js');
assert.match(gesture, /import \{ isDrawModeActive \} from '\.\.\/annotations\/drawMode\.js';/);
const click = gesture.slice(gesture.indexOf('eventTypes.LEFT_CLICK') - 400, gesture.indexOf('eventTypes.LEFT_CLICK'));
assert.ok(click.indexOf('if (isDrawModeActive()) return;') < click.indexOf('onClick(click, gesture);'), 'draw-mode guard must run before onClick');
});
test('the engine resolves manual geometry before any name resolution', () => {
const engine = read('src/annotations/annotationEngine.js');
const resolveAt = engine.indexOf('async function resolveSpec(spec, signal) {');
const manualAt = engine.indexOf('if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);', resolveAt);
const routeAt = engine.indexOf("if (type === 'route') {", resolveAt);
assert.ok(manualAt > resolveAt && manualAt < routeAt, 'manual specs must short-circuit ahead of the route/arrow/area resolvers');
assert.match(engine, /if \(mode === 'manual'\) return baseLabel \? `\$\{baseLabel\} — \$\{dist\}` : dist;/, 'a drawn line reports length, never a walk time');
});
test('the Draw control markup carries the ids the tool binds to', () => {
const html = read('index.html');
for (const id of ['draw-toggle', 'draw-mode-row', 'draw-label-row', 'draw-label-input', 'draw-color-select', 'draw-hint']) {
assert.match(html, new RegExp(`id="${id}"`), `#${id} missing from index.html`);
}
for (const shape of ['area', 'line', 'pin']) {
assert.match(html, new RegExp(`data-shape="${shape}"`), `shape button ${shape} missing`);
}
const tool = read('src/annotations/drawTool.js');
for (const id of ['draw-toggle', 'draw-mode-row', 'draw-label-row', 'draw-label-input', 'draw-color-select', 'draw-hint']) {
assert.match(tool, new RegExp(`getElementById\\('${id}'\\)`), `drawTool must bind #${id}`);
}
});
test('the tool restores the viewer double-click it borrows', () => {
const tool = read('src/annotations/drawTool.js');
assert.match(tool, /stock\.removeInputAction\(Cesium\.ScreenSpaceEventType\.LEFT_DOUBLE_CLICK\);/);
assert.match(tool, /if \(savedDoubleClick\) viewer\.screenSpaceEventHandler\.setInputAction\(savedDoubleClick, Cesium\.ScreenSpaceEventType\.LEFT_DOUBLE_CLICK\);/);
});

View File

@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isDrawModeActive } from '../annotations/drawMode.js';
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
export const MAX_TRACKING_CLICK_DURATION_MS = 400;
@ -95,6 +96,9 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
if (pressActive) finishPress(click?.position);
const gesture = completedGesture || { travelPx: 0, durationMs: 0 };
completedGesture = null;
// A manual draw session owns scene clicks: a vertex over an aircraft is a
// vertex, not a track request (src/annotations/drawTool.js).
if (isDrawModeActive()) return;
onClick(click, gesture);
}, eventTypes.LEFT_CLICK);
}

View File

@ -21,6 +21,7 @@ import { SceneDirector } from './scenes/director.js';
import { initGevVoiceCommands } from './voice/gevRealtime.js';
import { MapStackController } from './mapStackController.js';
import { initAnnotations } from './annotations/index.js';
import { initDrawTool } from './annotations/drawTool.js';
import { initLogoGaze } from './logoGaze.js';
import { initCockpitCloudEffects } from './cockpitCloudEffects.js';
import {
@ -244,6 +245,8 @@ async function init() {
// Initialize the voice "whiteboard" annotation engine (world-space renderer)
const annotations = initAnnotations({ viewer, tileset });
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand.
initDrawTool({ viewer, annotations });
// Keep startup chrome truthful: a share is not restored until camera,
// visual/map/panel lanes, and every requested layer have terminated.

View File

@ -1352,6 +1352,35 @@ body.cockpit-mode #cockpit-cloud-effects.active {
outline: none;
}
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
#draw-mode-row { min-width: 184px; }
#draw-label-row { flex-wrap: wrap; row-gap: 6px; }
.pp-text-input {
flex: 1 1 110px;
min-width: 0;
height: 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
outline: none;
}
.pp-text-input:focus { border-color: var(--accent); }
.draw-color-select { flex: 0 0 74px; }
.draw-hint {
flex-basis: 100%;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.6px;
color: var(--text-dim);
line-height: 1.4;
}
body.gev-drawing #cesiumContainer canvas { cursor: crosshair; }
/* Proximity / All segmented control under the 3D toggle */
#models3d-mode-row { min-width: 184px; }
.pp-mode-seg {