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:
parent
759652207f
commit
4a444a7a26
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -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, 150–800 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.
|
||||
|
|
|
|||
25
index.html
25
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)} m²`;
|
||||
}
|
||||
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.`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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\);/);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
29
style.css
29
style.css
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue