diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e6431c3..fc5df3599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,8 +115,30 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md - Separate submarine cable sources and rendering components, and export bundled geography lookup modules. +### 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; Backspace + undoes a vertex, Esc cancels the shape and a second Esc leaves draw mode, and + Clear wipes the board. Drawn shapes go through the same annotation engine as + spoken ones, so they render with the whiteboard look, persist, de-dup and clear + together. While you are drawing, the draw tool owns the pointer and no layer + selects what you click through (#235 — thanks @cora-fresh-labs). + ### Fixed +- Draped annotation geometry — area fills and outlines, routes and arrows — + classifies onto terrain as well as 3D tiles. On a keyless boot, where Cesium's + own globe carries the imagery, marks previously rendered their labels and no + geometry at all. This affected spoken annotations as much as hand-drawn ones. + +- A finished drawn area closes its ring, so its outline no longer misses the + edge back to the first vertex. + +- Areas measured and anchored across the antimeridian use unwrapped longitudes: + a shape straddling 180° reported an area thousands of times too large and + placed its label on the opposite side of the world. + - Traffic now retries a failed destination after city navigation without a layer toggle. Camera departure cancels pending work, arrival checks the final view, and superseded requests cannot keep a newer view loading. diff --git a/README.md b/README.md index b169189cc..fa2945eee 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,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, 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) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bc79b0098..33cda95b5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3002,6 +3002,23 @@ are omitted rather than framing the wrong part of the globe. - 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, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone. +- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is a DISPLAY-rail component template (`src/ui/templates/display-controls.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is 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 clear with `clear_annotations` / `.clear()`. +- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. +- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". +- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew. +- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. +- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. +- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. +- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing. +- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed. +- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas, the page body and the tool's own label field. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape. +- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. +- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. + ### 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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index a3225a0c0..64d89d4c0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -16,6 +16,12 @@ "src/data/infrastructure.test.mjs", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", + "src/data/inputOwnership.js", + "src/annotations/drawMode.js", + "src/annotations/drawMode.test.mjs", + "src/annotations/drawTool.js", + "src/annotations/drawTool.test.mjs", + "src/data/inputOwnership.test.mjs", "docs/CODE-BOUNDARIES.md", "src/main.js", "docs/APPLICATION.md", @@ -156,6 +162,7 @@ "src/styles/noir.js", "src/styles/snow.js", "scripts/qa-visual-effects.mjs", + "scripts/qa-draw-tool.mjs", "src/ui/mapSource.js", "src/ui/mapSourceControls.js", "src/ui/mapSourceControls.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6003bbdce..74ec4b078 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -8,7 +8,8 @@ "modules": [ "src/data/infrastructure.js", "src/data/localGeojsonCore.js", - "src/data/localGeojsonLod.js" + "src/data/localGeojsonLod.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -628,7 +629,8 @@ "src/layers/flights/tracking.js", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js" + "src/layers/flights/snapshotRenderer.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -662,7 +664,8 @@ "src/layers/military/tracking.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", - "src/layers/military/snapshotRenderer.js" + "src/layers/military/snapshotRenderer.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -685,7 +688,8 @@ "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", "src/layers/aircraft/classification.js", - "src/layers/aircraft/index.js" + "src/layers/aircraft/index.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -708,7 +712,8 @@ "src/data/vesselLabels.js", "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js" + "src/layers/vessels/snapshotRenderer.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -739,7 +744,8 @@ "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", - "src/layers/firms/viewport.js" + "src/layers/firms/viewport.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -761,7 +767,8 @@ "src/layers/satellites/source.js", "src/layers/satellites/state.js", "src/layers/satellites/testing.js", - "src/layers/satellites/tracking.js" + "src/layers/satellites/tracking.js", + "src/data/inputOwnership.js" ], "external": ["cesium", "satellite.js"] }, @@ -786,7 +793,8 @@ "src/layers/launches/selection.js", "src/layers/launches/source.js", "src/layers/launches/state.js", - "src/layers/launches/testing.js" + "src/layers/launches/testing.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -805,7 +813,8 @@ "src/layers/installations/selection.js", "src/layers/installations/source.js", "src/layers/installations/state.js", - "src/layers/installations/viewport.js" + "src/layers/installations/viewport.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -879,7 +888,8 @@ "src/layers/bikeshare/source.js", "src/layers/bikeshare/state.js", "src/layers/bikeshare/testing.js", - "src/layers/bikeshare/viewport.js" + "src/layers/bikeshare/viewport.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -916,7 +926,8 @@ "src/layers/cctv/state.js", "src/layers/cctv/testing.js", "src/overlays/worldOverlayTokens.js", - "src/data/cctvFootprint.js" + "src/data/cctvFootprint.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -927,7 +938,8 @@ "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", - "src/layers/alpr/source.js" + "src/layers/alpr/source.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -959,7 +971,8 @@ "src/layers/radio/tuning.js", "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", - "src/ui/radioTunerModel.js" + "src/ui/radioTunerModel.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -975,7 +988,8 @@ "src/layers/submarineCables/policy.js", "src/layers/submarineCables/rendering.js", "src/layers/submarineCables/state.js", - "src/layers/submarineCables/surface.js" + "src/layers/submarineCables/surface.js", + "src/data/inputOwnership.js" ], "external": ["cesium"] }, @@ -1557,7 +1571,10 @@ "src/layers/military/snapshotRenderer.js", "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js" + "src/layers/vessels/snapshotRenderer.js", + "src/data/inputOwnership.js", + "src/annotations/drawMode.js", + "src/annotations/drawTool.js" ], "external": [ "@mapbox/vector-tile", @@ -1889,7 +1906,8 @@ "src/layers/military/snapshotRenderer.js", "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js" + "src/layers/vessels/snapshotRenderer.js", + "src/data/inputOwnership.js" ], "external": [ "cesium", diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs new file mode 100644 index 000000000..a5a16af1d --- /dev/null +++ b/scripts/qa-draw-tool.mjs @@ -0,0 +1,876 @@ +#!/usr/bin/env node +/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; +const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw'; +const SHOT_PREFIX = process.env.QA_SHOT_PREFIX || ''; +const HEADFUL = process.env.QA_HEADFUL === '1'; +// A dense, recognisable grid: neighbourhood blocks to enclose and a straight +// road to trace along. +const VIEW = { + lon: -122.4194, + lat: 37.7749, + height: 900, + heading: 0, + pitch: -85, +}; + +const browser = await puppeteer.launch({ + headless: !HEADFUL, + ...(HEADFUL + ? { + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + } + : {}), + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ...(HEADFUL ? ['--window-size=1280,860'] : []), + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); +let failures = 0; +function check(name, passed, detail = '') { + console.log( + `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`, + ); + if (!passed) failures++; +} +fs.mkdirSync(SHOT_DIR, { recursive: true }); +const shot = (name) => + page.screenshot({ + path: path.join(SHOT_DIR, `${SHOT_PREFIX}${name}.jpg`), + type: 'jpeg', + quality: 82, + }); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** A clip of the globe canvas, clear of the panels on every side. */ +const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 }; + +/** + * How many amber pixels the clip contains. The drawn area's stroke and fill are + * the only amber in this scene, so the count going UP after a shape is finished + * is the shape actually painting — which is what CESIUM_3D_TILE-only + * classification failed to do on a keyless boot while still reporting a mark on + * the board. Decoding happens in the page because Node here has no image + * decoder. + */ +async function amberPixels() { + const shot64 = await page.screenshot({ + clip: CANVAS_CLIP, + type: 'png', + encoding: 'base64', + }); + return page.evaluate(async (base64) => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/png;base64,${base64}`)).blob(), + ); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height); + let amber = 0; + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + // Warm and saturated: the amber mark (#ffb547) blended over imagery. + if (r > 150 && g > 90 && g < 220 && b < 130 && r - b > 70 && r - g > 20) + amber += 1; + } + return amber; + }, shot64); +} + +/** + * Press Enter as the person drawing does — with the map focused, not a button. + * Enter on a focused control means "press this control", so the tool ignores it + * there; the harness has to move focus the way a user would before using the + * finish key. + */ +async function pressFinish() { + await page.evaluate(() => document.activeElement?.blur?.()); + await page.keyboard.press('Enter'); +} + +/** One click on the globe canvas, the way a person makes one. */ +async function clickWorld(x, y, options = {}) { + await page.mouse.click(x, y, options); + await wait(options.settle ?? 140); +} + +const drawState = () => + page.evaluate(() => { + const tool = window.__gevDrawTool; + return { + ...tool.diagnostics(), + shape: tool.shape, + vertices: tool.session?.vertices?.length ?? null, + hint: document.getElementById('draw-hint')?.textContent || '', + marks: window.__gevAnnotations.count(), + }; + }); + +const marks = () => + page.evaluate(() => + window.__gevAnnotations.list().map((mark) => ({ + type: mark.type, + label: mark.label, + color: mark.color, + source: mark.source ?? null, + })), + ); + +/** Wait until the board has settled at `expected` marks (annotate is async). */ +async function waitForMarks(expected, timeout = 15_000) { + try { + await page.waitForFunction( + (want) => window.__gevAnnotations.count() === want, + { timeout }, + expected, + ); + return true; + } catch { + return false; + } +} + +try { + await page.setViewport({ width: 1280, height: 860 }); + await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__gevDrawTool && + window.__gevAnnotations && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 90_000 }, + ); + await page.evaluate((view) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.scene.tweens?.removeAll?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (view.lon * Math.PI) / 180, + latitude: (view.lat * Math.PI) / 180, + height: view.height, + }), + orientation: { + heading: (view.heading * Math.PI) / 180, + pitch: (view.pitch * Math.PI) / 180, + roll: 0, + }, + }); + viewer.scene.requestRender(); + }, VIEW); + const settled = await page + .waitForFunction( + () => { + const primitives = window.__godsEyeView.viewer.scene.primitives; + for (let index = 0; index < primitives.length; index++) { + const primitive = primitives.get(index); + if ( + typeof primitive.tilesLoaded === 'boolean' && + !primitive.tilesLoaded + ) + return false; + } + return true; + }, + { timeout: 90_000 }, + ) + .then( + () => true, + () => false, + ); + check('visible tile content settles before drawing', settled); + + // The DISPLAY rail starts collapsed on a first run: open it the way a user does. + await page.evaluate(() => { + const rail = document.getElementById('pp-toggles'); + if (rail?.classList.contains('collapsed')) + document + .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]') + ?.click(); + }); + await page.waitForFunction( + () => + document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0, + { timeout: 10_000 }, + ); + check( + 'the Draw control is on the DISPLAY rail and starts off', + await page.evaluate(() => { + const toggle = document.getElementById('draw-toggle'); + return ( + toggle?.getAttribute('aria-pressed') === 'false' && + !document.getElementById('draw-mode-row')?.classList.contains('visible') + ); + }), + ); + + // The viewer's OWN click actions, captured before the tool ever runs. Both + // must be absent while drawing and back — as the SAME functions — after. + await page.evaluate(() => { + const Cesium = window.__CESIUM__; + const stock = window.__godsEyeView.viewer.screenSpaceEventHandler; + window.__qaStock = { + click: + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null, + double: + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || + null, + }; + }); + const stockActions = () => + page.evaluate(() => { + const Cesium = window.__CESIUM__; + const stock = window.__godsEyeView.viewer.screenSpaceEventHandler; + const live = { + click: + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null, + double: + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || + null, + }; + return { + clickPresent: Boolean(live.click), + doublePresent: Boolean(live.double), + clickIsOriginal: live.click === window.__qaStock.click, + doubleIsOriginal: live.double === window.__qaStock.double, + hadClick: Boolean(window.__qaStock.click), + hadDouble: Boolean(window.__qaStock.double), + }; + }); + + // ── enable / disable cycles leave nothing behind ──────────────────────── + const before = await drawState(); + for (let cycle = 0; cycle < 3; cycle += 1) { + await page.click('#draw-toggle'); + await wait(120); + await page.click('#draw-toggle'); + await wait(120); + } + const after = await drawState(); + check( + 'three enable/disable cycles add no listener, handler or preview data source', + after.domListeners === before.domListeners && + after.previewDataSources === 1 && + before.previewDataSources === 1 && + after.sceneHandler === false && + after.pointerOwner === null && + after.stockDoubleClick === before.stockDoubleClick, + `listeners ${before.domListeners}→${after.domListeners}, previews ${after.previewDataSources}, ` + + `handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`, + ); + + // ── the viewer's own click actions are borrowed, then given back ──────── + await page.click('#draw-toggle'); + const duringDraw = await stockActions(); + check( + "Cesium's own selecting click and tracking double-click are both borrowed while drawing", + duringDraw.clickPresent === false && duringDraw.doublePresent === false, + `click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` + + `(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`, + ); + await page.click('#draw-toggle'); + const afterDraw = await stockActions(); + check( + 'and both are restored as the SAME functions, not replacements', + afterDraw.clickPresent === duringDraw.hadClick && + afterDraw.doublePresent === duringDraw.hadDouble && + afterDraw.clickIsOriginal && + afterDraw.doubleIsOriginal, + JSON.stringify(afterDraw), + ); + + // ── a pin, finished with Enter ────────────────────────────────────────── + await page.click('#draw-toggle'); + await page.click('.pp-mode-btn[data-shape="pin"]'); + await page.click('#draw-label-input'); + await page.keyboard.type('Pin one'); + await clickWorld(520, 330); + check( + 'the pointer is claimed while a session is open', + (await drawState()).pointerOwner === 'draw', + ); + await page.keyboard.press('Enter'); + check( + 'a pin placed by mouse and finished with Enter lands on the board', + await waitForMarks(1), + ); + + // ── a line along a road, with Backspace ───────────────────────────────── + await page.click('.pp-mode-btn[data-shape="line"]'); + await clickWorld(360, 300); + await clickWorld(470, 380); + await clickWorld(600, 470); + const beforeBackspace = (await drawState()).vertices; + await page.keyboard.press('Backspace'); + await wait(120); + const afterBackspace = (await drawState()).vertices; + check( + 'Backspace removes the last vertex only', + beforeBackspace === 3 && afterBackspace === 2, + `${beforeBackspace} → ${afterBackspace}`, + ); + await clickWorld(640, 500); + await page.click('#draw-label-input'); + await page.keyboard.type('Market St'); + await page.keyboard.press('Enter'); + check( + 'a line finished from the label field lands on the board', + await waitForMarks(2), + ); + const lineMark = (await marks()).find((mark) => + mark.label?.startsWith('Market St'), + ); + check( + 'a drawn line reports its length and never a travel time', + Boolean(lineMark) && + /\d/.test(lineMark.label) && + !/min|walk|drive/i.test(lineMark.label), + lineMark?.label, + ); + await shot('02-line-along-a-road'); + + // ── an area, coloured and labelled, finished with a double-click ──────── + await page.click('.pp-mode-btn[data-shape="area"]'); + await page.select('#draw-color-select', 'amber'); + const amberBefore = await amberPixels(); + await page.click('#draw-label-input'); + await page.keyboard.type('Civic Center block'); + await clickWorld(400, 280); + await clickWorld(700, 290); + await clickWorld(720, 520); + await clickWorld(410, 500); + const areaVertices = (await drawState()).vertices; + check( + 'four clicks on the globe are four vertices', + areaVertices === 4, + `${areaVertices} vertices`, + ); + await page.mouse.click(410, 500, { count: 2 }); + const areaLanded = await waitForMarks(3); + await wait(900); + const afterDoubleClick = await page.evaluate(() => + window.__gevAnnotations.count(), + ); + check('an area finished by double-click lands on the board', areaLanded); + check( + 'a double-click finishes the shape exactly once', + afterDoubleClick === 3, + `${afterDoubleClick} marks`, + ); + const areaMark = (await marks()).find( + (mark) => mark.label === 'Civic Center block', + ); + check( + 'the label and colour chosen in the panel are what is drawn', + areaMark?.color === 'amber', + JSON.stringify(areaMark), + ); + await wait(1200); + const amberAfter = await amberPixels(); + check( + 'the finished area actually PAINTS — amber pixels appear on this surface', + amberAfter - amberBefore > 500, + `${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`, + ); + await shot('01-area-with-label'); + await shot('03-pins-and-shapes'); + + // ── Escape cancels the shape, a second Escape leaves draw mode ────────── + await page.evaluate(() => window.__gevDrawTool.cancel()); + await clickWorld(500, 300); + await clickWorld(560, 340); + check('a new shape is in progress', (await drawState()).vertices === 2); + await page.keyboard.press('Escape'); + await wait(150); + const afterFirstEscape = await drawState(); + check( + 'Escape cancels the shape in progress and stays in draw mode', + afterFirstEscape.vertices === 0 && afterFirstEscape.active === true, + `vertices ${afterFirstEscape.vertices}, active ${afterFirstEscape.active}`, + ); + await page.keyboard.press('Escape'); + await wait(150); + const afterSecondEscape = await drawState(); + check( + 'a second Escape leaves draw mode and gives the pointer back', + afterSecondEscape.active === false && + afterSecondEscape.pointerOwner === null && + afterSecondEscape.sceneHandler === false, + JSON.stringify(afterSecondEscape), + ); + check( + 'cancelling never removes a mark already on the board', + afterSecondEscape.marks === 3, + `${afterSecondEscape.marks} marks`, + ); + + // ── a click that would select something selects nothing while drawing ─── + const target = await findClickableTarget(); + check( + 'a selectable layer entity is on screen to test click ownership against', + Boolean(target), + target + ? `${target.layer} at ${target.x},${target.y}` + : 'no CCTV camera or bundled site could be reached — the ownership case cannot be proved', + ); + if (target) { + await clickWorld(target.x, target.y, { settle: 900 }); + const selectedNormally = await page.evaluate(() => + Boolean(window.__godsEyeView.viewer.selectedEntity), + ); + check( + `clicking a ${target.layer} selects it when nothing owns the pointer`, + selectedNormally, + ); + await page.evaluate(() => { + window.__godsEyeView.viewer.selectedEntity = undefined; + }); + await page.click('#draw-toggle'); + await page.click('.pp-mode-btn[data-shape="area"]'); + await clickWorld(target.x, target.y, { settle: 900 }); + const whileDrawing = await drawState(); + const selectedWhileDrawing = await page.evaluate(() => + Boolean(window.__godsEyeView.viewer.selectedEntity), + ); + check( + `clicking the same ${target.layer} while drawing adds a vertex and selects nothing`, + whileDrawing.vertices === 1 && selectedWhileDrawing === false, + `vertices ${whileDrawing.vertices}, selected ${selectedWhileDrawing}`, + ); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + await wait(150); + } + + // ── what a reload actually does ───────────────────────────────────────── + const beforeReload = await page.evaluate(() => + window.__gevAnnotations.count(), + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__gevAnnotations && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 90_000 }, + ); + const afterReload = await page.evaluate(() => + window.__gevAnnotations.count(), + ); + check( + 'a reload starts an empty board — marks live for the session, not across reloads', + beforeReload > 0 && afterReload === 0, + `${beforeReload} before, ${afterReload} after`, + ); + + // ── Clear wipes the board ─────────────────────────────────────────────── + await page.evaluate(() => { + const rail = document.getElementById('pp-toggles'); + if (rail?.classList.contains('collapsed')) + document + .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]') + ?.click(); + }); + await page.waitForFunction( + () => + document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0, + { timeout: 10_000 }, + ); + // The reload built a new page context: re-capture the viewer's own click + // actions so the identity comparisons below still mean something. + await page.evaluate(() => { + const Cesium = window.__CESIUM__; + const stock = window.__godsEyeView.viewer.screenSpaceEventHandler; + window.__qaStock = { + click: + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null, + double: + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || + null, + }; + }); + await page.evaluate((view) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (view.lon * Math.PI) / 180, + latitude: (view.lat * Math.PI) / 180, + height: view.height, + }), + orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, + }); + }, VIEW); + await wait(2500); + await page.click('#draw-toggle'); + await page.click('.pp-mode-btn[data-shape="area"]'); + await clickWorld(420, 280); + await clickWorld(700, 300); + await clickWorld(660, 520); + await pressFinish(); + const drewAgain = await waitForMarks(1); + check('a shape can be drawn again after a reload', drewAgain); + + // Enter belongs to whatever the keyboard is on. With Clear focused it must + // PRESS CLEAR — the shape in progress goes, and so does the mark already on + // the board — and it must NOT quietly finish a second shape. + await clickWorld(430, 300); + await clickWorld(690, 320); + await clickWorld(670, 500); + const beforeButtonEnter = await drawState(); + await page.focus('#draw-clear'); + await page.keyboard.press('Enter'); + await wait(600); + const afterButtonEnter = await drawState(); + check( + 'Enter on a keyboard-focused button activates the button, it does not finish the shape', + beforeButtonEnter.vertices === 3 && + beforeButtonEnter.marks === 1 && + afterButtonEnter.marks === 0 && + afterButtonEnter.vertices === 0, + `before ${beforeButtonEnter.vertices} vertices / ${beforeButtonEnter.marks} marks, ` + + `after ${afterButtonEnter.vertices} / ${afterButtonEnter.marks} ` + + '(a finish would have made it 2 marks)', + ); + + // Put one back so the Clear BUTTON check below has something to remove. + await clickWorld(420, 280); + await clickWorld(700, 300); + await clickWorld(660, 520); + await pressFinish(); + check( + 'a shape can be drawn after the board was cleared from the keyboard', + await waitForMarks(1), + ); + await page.click('#draw-clear'); + await wait(400); + const cleared = await drawState(); + check( + 'Clear removes every mark from the board', + cleared.marks === 0, + `${cleared.marks} marks left`, + ); + await shot('04-after-clear'); + + // ── a shape that describes nothing is refused, not placed ─────────────── + const degenerateVertices = await page.evaluate(() => { + const tool = window.__gevDrawTool; + tool.cancel(); + // Three points on one line, placed through the same addVertex the clicks use. + tool.addVertex(-122.42, 37.77); + tool.addVertex(-122.42, 37.771); + tool.addVertex(-122.42, 37.772); + return tool.session?.vertices?.length ?? null; + }); + check( + 'three collinear vertices are accepted into the session', + degenerateVertices === 3, + `${degenerateVertices} vertices`, + ); + await pressFinish(); + await wait(600); + const degenerate = await drawState(); + check( + 'three points in a line are refused with a reason, not placed', + degenerate.marks === 0 && /in a line/.test(degenerate.hint), + `${degenerate.marks} marks, hint "${degenerate.hint}"`, + ); + + // ── a shape drawn across the antimeridian lands where it was drawn ────── + await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (179.9995 * Math.PI) / 180, + latitude: 0, + height: 3000, + }), + orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await wait(2500); + await page.evaluate(() => { + const tool = window.__gevDrawTool; + tool.cancel(); + tool.setShape('area'); + // A ~200 m square straddling 180. + tool.addVertex(179.999, -0.001); + tool.addVertex(-179.999, -0.001); + tool.addVertex(-179.999, 0.001); + tool.addVertex(179.999, 0.001); + }); + await pressFinish(); + const datelineLanded = await waitForMarks(1); + const datelineAnchor = await page.evaluate(() => { + const mark = window.__gevAnnotations.list()[0]; + return mark ? { lon: mark.anchor?.lon, lat: mark.anchor?.lat } : null; + }); + check( + 'a shape drawn across the antimeridian anchors there, not on the Greenwich meridian', + datelineLanded && + datelineAnchor && + Math.abs(Math.abs(datelineAnchor.lon) - 180) < 0.1 && + Math.abs(datelineAnchor.lat) < 0.1, + JSON.stringify(datelineAnchor), + ); + await page.evaluate(() => window.__gevAnnotations.clear()); + + await page.click('#draw-toggle'); + await wait(200); + const finalState = await drawState(); + check( + 'leaving draw mode gives back the pointer, the handler and the double-click', + finalState.active === false && + finalState.pointerOwner === null && + finalState.sceneHandler === false && + finalState.previewDataSources === 1, + JSON.stringify(finalState), + ); + // ── destroy(): the shell disposing the tool leaves nothing behind ─────── + const beforeDestroy = await page.evaluate( + () => window.__godsEyeView.viewer.dataSources.length, + ); + await page.click('#draw-toggle'); // destroy from an ACTIVE session: the harder case + await wait(200); + const destroyed = await page.evaluate(async () => { + const tool = window.__gevDrawTool; + const wasActive = tool.active; + await tool.destroy(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const Cesium = window.__CESIUM__; + const viewer = window.__godsEyeView.viewer; + const stock = viewer.screenSpaceEventHandler; + let previews = 0; + for (let i = 0; i < viewer.dataSources.length; i += 1) + if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1; + return { + wasActive, + diagnostics: tool.diagnostics(), + previews, + dataSources: viewer.dataSources.length, + handleGone: window.__gevDrawTool === undefined, + clickRestored: + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) === + window.__qaStock.click, + doubleRestored: + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) === + window.__qaStock.double, + drawingClass: document.body.classList.contains('gev-drawing'), + }; + }); + check( + 'destroy() from an active session releases the pointer, handler and listeners', + destroyed.wasActive === true && + destroyed.diagnostics.destroyed === true && + destroyed.diagnostics.pointerOwner === null && + destroyed.diagnostics.sceneHandler === false && + destroyed.diagnostics.domListeners === 0 && + destroyed.drawingClass === false, + JSON.stringify(destroyed.diagnostics), + ); + check( + 'destroy() detaches the preview data source and gives the window handle back', + destroyed.previews === 0 && + destroyed.dataSources === beforeDestroy - 1 && + destroyed.handleGone, + `previews ${destroyed.previews}, dataSources ${beforeDestroy} → ${destroyed.dataSources}, handle gone ${destroyed.handleGone}`, + ); + check( + "destroy() returns Cesium's own click actions", + destroyed.clickRestored && destroyed.doubleRestored, + `click ${destroyed.clickRestored}, double ${destroyed.doubleRestored}`, + ); + + check( + 'no uncaught page errors across the run', + errors.length === 0, + errors.join(' | '), + ); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; + +/** + * Find something on screen that a layer would select on click, so the ownership + * guard can be proved against a real selection rather than a stub. Bundled + * infrastructure first (no network, works keyless); CCTV cameras if they are up. + */ +async function findClickableTarget() { + // CCTV first: it is the layer the brief names, and it goes through the shared + // tracking click gesture that flights and military use too. + const camera = await page + .evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + await manager.setEnabled('cctv', true, { origin: 'user' }); + const module = manager.layers.get('cctv')?.module; + if (!module?.getUIState) return null; + for (let attempt = 0; attempt < 40; attempt += 1) { + const cameras = module.getUIState()?.cameras || []; + const found = cameras.find((entry) => { + const lat = entry.lat ?? entry.latitude; + const lon = entry.lon ?? entry.longitude; + return Number.isFinite(lat) && Number.isFinite(lon); + }); + if (found) + return { + id: found.id, + lat: found.lat ?? found.latitude, + lon: found.lon ?? found.longitude, + }; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return null; + }) + .catch(() => null); + + if (camera) { + await page.evaluate((spot) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (spot.lon * Math.PI) / 180, + latitude: (spot.lat * Math.PI) / 180, + height: 600, + }), + orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 }, + }); + viewer.scene.requestRender(); + }, camera); + await wait(4000); + const spot = await onScreen( + (entity) => typeof entity.id === 'string' && entity.id.startsWith('CAM-'), + ); + if (spot) return { ...spot, layer: 'CCTV camera' }; + } + + // Bundled infrastructure as the keyless fallback: a different guarded handler, + // the same shared claim, and always present without a key. + const site = await page + .evaluate(async () => { + const viewer = window.__godsEyeView.viewer; + await window.__godsEyeView.dataManager.setEnabled( + 'local-datacenters', + true, + { + origin: 'user', + }, + ); + const centreOf = (entity) => { + const time = viewer.clock.currentTime; + const point = entity.position?.getValue?.(time); + if (point) return point; + const hierarchy = entity.polygon?.hierarchy?.getValue?.(time); + if (hierarchy?.positions?.length) return hierarchy.positions[0]; + const line = entity.polyline?.positions?.getValue?.(time); + return line?.length ? line[0] : null; + }; + for (let attempt = 0; attempt < 40; attempt += 1) { + for (let i = 0; i < viewer.dataSources.length; i += 1) { + for (const entity of viewer.dataSources.get(i).entities.values) { + if (entity.__localLayerId !== 'local-datacenters') continue; + const point = centreOf(entity); + if (!point) continue; + const carto = window.Cesium + ? window.Cesium.Cartographic.fromCartesian(point) + : viewer.scene.globe.ellipsoid.cartesianToCartographic(point); + if (!carto) continue; + return { + lon: (carto.longitude * 180) / Math.PI, + lat: (carto.latitude * 180) / Math.PI, + }; + } + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return null; + }) + .catch(() => null); + if (!site) return null; + await page.evaluate((spot) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (spot.lon * Math.PI) / 180, + latitude: (spot.lat * Math.PI) / 180, + height: 2500, + }), + orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 }, + }); + viewer.scene.requestRender(); + }, site); + await wait(4000); + const spot = await onScreen( + (entity) => entity.__localLayerId === 'local-datacenters', + ); + return spot ? { ...spot, layer: 'data centre' } : null; +} + +/** + * Canvas coordinates of the first entity matching `belongs` that is on screen + * and clear of the side panels. Annotation marks and the draw tool's own + * preview vertices are on screen too, so the predicate has to be specific. + */ +async function onScreen(belongs, timeout = 20_000) { + return page + .waitForFunction( + (belongsSource) => { + // eslint-disable-next-line no-new-func + const belongsTo = new Function(`return (${belongsSource})`)(); + const viewer = window.__godsEyeView.viewer; + const scene = viewer.scene; + const collections = [viewer.entities]; + for (let i = 0; i < viewer.dataSources.length; i += 1) { + const source = viewer.dataSources.get(i); + if (source?.name === 'gev-draw-preview') continue; + collections.push(source.entities); + } + for (const source of collections) { + for (const entity of source.values) { + if (!belongsTo(entity)) continue; + const position = entity.position?.getValue?.( + viewer.clock.currentTime, + ); + if (!position) continue; + const window2d = scene.cartesianToCanvasCoordinates(position); + if (!window2d) continue; + const { x, y } = window2d; + if (!(x > 300 && x < 900 && y > 180 && y < 600)) continue; + // Projecting into the window is not the same as being clickable: + // the entity may be beyond the horizon or behind a building. Only a + // spot where the scene actually picks it proves the control case. + const hit = scene.pick({ x: Math.round(x), y: Math.round(y) }); + if (!hit?.id || !belongsTo(hit.id)) continue; + return { x: Math.round(x), y: Math.round(y) }; + } + } + return false; + }, + { timeout, polling: 500 }, + belongs.toString(), + ) + .then( + (handle) => handle.jsonValue(), + () => null, + ); +} diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 7c9e59583..e9f19c69f 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,7 +1,8 @@ import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; -import { isRateLimitedOutcome, resolveAnnotationTarget } from './annotationResolver.js'; +import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js'; +import { ringCentroid } from './drawMode.js'; // Dev convenience: expose the app's Cesium instance for console/preview probing // (single shared module instance — avoids dual-Cesium state bugs when testing). @@ -387,6 +388,9 @@ export function createAnnotationEngine({ async function resolveSpec(spec, signal, allowDistant = false) { 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'); @@ -1103,6 +1107,67 @@ 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') { + // height 0 is a placeholder, not a placement: the world renderer draws a + // route with clampToGround + CESIUM_3D_TILE classification, so the line is + // draped onto the photoreal surface whatever this number says. + 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'); + // Averaged through ringCentroid, which unwraps longitudes first: a ring + // straddling the antimeridian has coordinates like [179.999, -179.999], and + // a raw mean of those puts the anchor on the Greenwich meridian, half a + // world from the shape it belongs to. A repeated closing vertex is dropped + // so it does not weight its own corner twice. + const distinct = closedRingWithoutRepeat(ring); + const centre = ringCentroid(distinct.map(([lon, lat]) => ({ lon, lat }))); + const lon = centre.lon; + const lat = centre.lat; + 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' }; +} + +/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */ +function closedRingWithoutRepeat(ring) { + if (ring.length < 2) return ring; + const [firstLon, firstLat] = ring[0]; + const [lastLon, lastLat] = ring[ring.length - 1]; + return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring; +} + function normalizeMode(m) { const t = String(m || '').toLowerCase(); if (t === 'car' || t === 'drive' || t === 'driving') return 'car'; @@ -1138,6 +1203,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; diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index 960ea3a39..cfdba190a 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -1,4 +1,4 @@ import { createAnnotationResolver } from './resolver.js'; import { applicationServices } from '../services/application.js'; const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries }); -export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery } = resolver; +export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver; diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js new file mode 100644 index 000000000..f28a7ae6f --- /dev/null +++ b/src/annotations/drawMode.js @@ -0,0 +1,303 @@ +/** + * 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 and clears exactly like a spoken + * one. + * + * A vertex is a lon/lat and nothing more. The world renderer drapes every mark + * onto the photoreal surface (`clampToGround` + `CESIUM_3D_TILE`), so there is + * no such thing as a vertex at a height here: the height under the click is + * used for the live preview and is deliberately dropped at finish. + * + * 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 }); +/** + * Hard ceiling on vertices in one shape. Every vertex is a live preview entity + * and a position in the finished geometry, so a stuck mouse button or a script + * must not be able to grow one shape without limit. + */ +export const MAX_VERTICES = 512; +/** 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; +/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */ +export const MIN_PATH_LENGTH_M = 1; +export const MIN_AREA_M2 = 1; + +/** @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 || !isFiniteCoordinate(vertex)) + 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 }; + } + if (session.vertices.length >= MAX_VERTICES) + return { added: false, reason: 'full' }; + 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 }; +} + +/** A usable click position: finite, and on the globe rather than past its edges. */ +export function isFiniteCoordinate(vertex) { + if (!vertex) return false; + const { lon, lat } = vertex; + return ( + Number.isFinite(lon) && + Number.isFinite(lat) && + Math.abs(lon) <= 180 && + Math.abs(lat) <= 90 + ); +} + +/** 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; +} + +/** + * Why a session can or cannot become an annotation. + * - `too-few`: not enough vertices yet. + * - `degenerate`: enough vertices, but they describe nothing — three collinear + * points enclose no area, and a line that doubles back on itself has no + * length. Drawing one of those and calling it a mark would put an invisible + * entity on the board that the person cannot see, select or explain. + * @returns {'ok'|'too-few'|'degenerate'|'invalid'} + */ +export function finishReason(session) { + if (!session || !Array.isArray(session.vertices)) return 'invalid'; + if (session.vertices.some((vertex) => !isFiniteCoordinate(vertex))) + return 'invalid'; + if (session.vertices.length < (MIN_VERTICES[session.shape] || 1)) + return 'too-few'; + if ( + session.shape === 'line' && + pathLengthM(session.vertices) < MIN_PATH_LENGTH_M + ) + return 'degenerate'; + if (session.shape === 'area' && ringAreaM2(session.vertices) < MIN_AREA_M2) + return 'degenerate'; + return 'ok'; +} + +/** @returns {boolean} whether the session can become an annotation right now */ +export function canFinish(session) { + return finishReason(session) === 'ok'; +} + +/** 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; +} + +/** + * The same vertices with longitudes made CONTINUOUS relative to the first one, + * so planar maths does not tear at the antimeridian. A small shape straddling + * 180° has longitudes like [179.999, -179.999]; subtracting those raw gives + * 359.998° of width instead of 0.002°, which is how a 25,000 m² rectangle + * measured 4,461 km² and reported its centre on the Greenwich meridian. + * + * Values may leave the [-180, 180] range on purpose — that is what "continuous" + * means. Re-wrap with `wrapLongitude` before handing one back as a coordinate. + * @param {Array<{lon:number, lat:number}>} vertices + */ +export function unwrapLongitudes(vertices) { + if (!vertices?.length) return []; + const reference = vertices[0].lon; + return vertices.map((vertex) => { + let lon = vertex.lon; + while (lon - reference > 180) lon -= 360; + while (lon - reference < -180) lon += 360; + return { ...vertex, lon }; + }); +} + +/** A continuous longitude brought back into [-180, 180]. */ +export function wrapLongitude(lon) { + if (!Number.isFinite(lon)) return lon; + // A value already in range is returned UNCHANGED rather than pushed through + // the modulo, which is only exact in binary for some inputs: 0.0005 came back + // as 0.0004999999999881766. The seam is the only place the arithmetic is + // needed, so it is the only place that pays for it. + if (lon >= -180 && lon < 180) return lon; + const value = ((((lon + 180) % 360) + 360) % 360) - 180; + return Object.is(value, -0) ? 0 : value; +} + +/** 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 ring = unwrapLongitudes(vertices); + const lat0 = ring.reduce((s, v) => s + v.lat, 0) / ring.length; + const kx = 111320 * Math.cos((lat0 * Math.PI) / 180); + const ky = 111320; + let twice = 0; + for (let i = 0; i < ring.length; i += 1) { + const a = ring[i]; + const b = ring[(i + 1) % ring.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}, safe across the antimeridian. */ +export function ringCentroid(vertices) { + if (!vertices?.length) return null; + const ring = unwrapLongitudes(vertices); + return { + lon: wrapLongitude(ring.reduce((s, v) => s + v.lon, 0) / ring.length), + lat: ring.reduce((s, v) => s + v.lat, 0) / ring.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. + * + * The per-vertex height from the click is dropped here, deliberately and in one + * place: the world renderer drapes areas and routes onto the photoreal surface, + * so a height carried this far would be discarded further downstream instead, + * silently. What the person gets is the outline they drew, lying on the + * surface under it. + * @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') { + // CLOSED explicitly. A polygon fill closes itself, but the outline beside it + // is a polyline, and an open ring draws every edge except the one back to + // the first vertex — the shape reads as a shape with one side missing. + return { + type: 'area', + manual: true, + ring: closeRing(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, + }; +} + +/** A ring whose last position repeats its first, so an outline has no gap. */ +export function closeRing(pairs) { + if (!Array.isArray(pairs) || pairs.length < 3) return pairs; + const [firstLon, firstLat] = pairs[0]; + const [lastLon, lastLat] = pairs[pairs.length - 1]; + if (firstLon === lastLon && firstLat === lastLat) return pairs; + return [...pairs, [firstLon, firstLat]]; +} + +/** 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'}.`; + if (finishReason(session) === 'degenerate') { + return session.shape === 'area' + ? 'Those points are in a line — move one off it to enclose an area.' + : 'That line has no length — click somewhere further away.'; + } + const full = + n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : ''; + return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`; +} diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs new file mode 100644 index 000000000..50475cc9e --- /dev/null +++ b/src/annotations/drawMode.test.mjs @@ -0,0 +1,332 @@ +// 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, + finishReason, + isFiniteCoordinate, + closeRing, + unwrapLongitudes, + wrapLongitude, + MAX_VERTICES, + 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, + // CLOSED: the last position repeats the first so the outline polyline + // draws all three sides of the triangle, not two. + ring: [ + [-97.74, 30.27], + [-97.73, 30.27], + [-97.73, 30.28], + [-97.74, 30.27], + ], + 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('a vertex must be a finite coordinate on the globe', () => { + assert.equal(isFiniteCoordinate({ lon: 12, lat: -4 }), true); + assert.equal(isFiniteCoordinate({ lon: 0, lat: 90 }), true); + for (const bad of [ + null, + {}, + { lon: NaN, lat: 0 }, + { lon: 0, lat: Infinity }, + { lon: 181, lat: 0 }, + { lon: 0, lat: -90.5 }, + { lon: '10', lat: 10 }, + ]) { + assert.equal(isFiniteCoordinate(bad), false, JSON.stringify(bad)); + } + const line = createDrawSession('line'); + assert.deepEqual(addVertex(line, { lon: 200, lat: 0 }), { + added: false, + reason: 'invalid', + }); + assert.equal(line.vertices.length, 0); +}); + +test('one shape holds at most MAX_VERTICES points', () => { + const line = createDrawSession('line'); + for (let i = 0; i < MAX_VERTICES + 25; i += 1) + addVertex(line, { lon: i * 0.001, lat: 0 }); + assert.equal(line.vertices.length, MAX_VERTICES); + assert.deepEqual(addVertex(line, { lon: 99, lat: 1 }), { + added: false, + reason: 'full', + }); + assert.match(drawHint(line), /512-point limit reached/); + + // A pin replaces its one vertex forever: the ceiling cannot strand it. + const pin = createDrawSession('pin'); + for (let i = 0; i < MAX_VERTICES + 5; i += 1) + assert.equal(addVertex(pin, { lon: i * 0.01, lat: 0 }).added, true); + assert.equal(pin.vertices.length, 1); +}); + +test('a shape that encloses nothing is refused, with a reason', () => { + // Three points on one meridian are not an area. + const collinear = createDrawSession('area'); + for (const lat of [0, 0.001, 0.002]) addVertex(collinear, { lon: 0, lat }); + assert.equal(collinear.vertices.length, 3); + assert.equal(finishReason(collinear), 'degenerate'); + assert.equal(canFinish(collinear), false); + assert.equal(finishSpec(collinear), null); + assert.match(drawHint(collinear), /in a line/); + + // Moving one point off that line makes it a shape again. + collinear.vertices[2].lon = 0.001; + assert.equal(finishReason(collinear), 'ok'); + assert.ok(finishSpec(collinear)); + + // A line whose two ends are a handful of centimetres apart has no length. + const stub = createDrawSession('line'); + addVertex(stub, { lon: 0, lat: 0 }); + addVertex(stub, { lon: 0.000007, lat: 0 }, { minSeparationM: 0.01 }); + assert.equal(stub.vertices.length, 2); + assert.equal(finishReason(stub), 'degenerate'); + assert.equal(finishSpec(stub), null); + assert.match(drawHint(stub), /no length/); + + // And the states in between are named, not lumped together. + assert.equal(finishReason(createDrawSession('area')), 'too-few'); + assert.equal(finishReason(null), 'invalid'); +}); + +test('a finished area is a CLOSED ring, so its outline has no missing side', () => { + const area = createDrawSession('area'); + for (const [lon, lat] of [ + [-97.74, 30.27], + [-97.73, 30.27], + [-97.73, 30.28], + ]) + addVertex(area, { lon, lat }); + + const spec = finishSpec(area); + assert.equal( + spec.ring.length, + 4, + 'three clicked vertices become four ring positions', + ); + assert.deepEqual( + spec.ring.at(-1), + spec.ring[0], + 'the last position repeats the first', + ); + // The renderer draws the outline as a polyline over exactly these positions: + // an open ring would draw two of the triangle's three sides. + assert.equal(spec.ring.length - 1, area.vertices.length); + + // A line is NOT closed — it is a path, and joining its ends would invent a + // segment the person never drew. + const line = createDrawSession('line'); + addVertex(line, { lon: 0, lat: 0 }); + addVertex(line, { lon: 0.01, lat: 0 }); + assert.equal(finishSpec(line).path.length, 2); + + // closeRing is idempotent: finishing an already-closed ring adds nothing. + const closed = closeRing([ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ]); + assert.equal(closed.length, 4); +}); + +test('a shape straddling the antimeridian measures what it looks like', () => { + // 0.002 degrees square at the equator — about 223 m a side, so ~49,600 m2 — + // sitting across 180. Raw longitude arithmetic reads that 0.002-degree width + // as 359.998 degrees: the area came out in the thousands of square + // kilometres and the centroid landed on the Greenwich meridian, half a world + // from the shape it belongs to. + const rectangle = [ + { lon: 179.999, lat: -0.001 }, + { lon: -179.999, lat: -0.001 }, + { lon: -179.999, lat: 0.001 }, + { lon: 179.999, lat: 0.001 }, + ]; + const area = ringAreaM2(rectangle); + assert.ok( + area > 45_000 && area < 55_000, + `a ~223 m x ~223 m patch is ~49,600 m2, got ${Math.round(area)} m2`, + ); + assert.ok(area < 1e6, 'and nowhere near a square kilometre'); + + const centre = ringCentroid(rectangle); + assert.ok( + Math.abs(Math.abs(centre.lon) - 180) < 0.01, + `the centre belongs on the antimeridian, got lon ${centre.lon}`, + ); + assert.ok(Math.abs(centre.lat) < 0.01, `got lat ${centre.lat}`); + + // Length was already safe — haversine is periodic in the longitude delta — + // and this pins that so a "fix" cannot break it. + const across = pathLengthM([ + { lon: 179.999, lat: 0 }, + { lon: -179.999, lat: 0 }, + ]); + assert.ok( + across > 150 && across < 300, + `about 222 m, got ${Math.round(across)} m`, + ); + + // The same shape away from the seam still measures the same. + const inland = rectangle.map((v) => ({ lon: v.lon - 179.999, lat: v.lat })); + assert.ok( + Math.abs(ringAreaM2(inland) - area) < 1, + 'position must not change size', + ); + + // And the ordinary case is untouched. + const austin = [ + { lon: -97.75, lat: 30.26 }, + { lon: -97.74, lat: 30.26 }, + { lon: -97.74, lat: 30.27 }, + ]; + const centroid = ringCentroid(austin); + assert.ok(Math.abs(centroid.lon + 97.7433) < 0.001); + assert.ok(Math.abs(centroid.lat - 30.2633) < 0.001); +}); + +test('longitude unwrapping is continuous and re-wraps to one canonical range', () => { + const unwrapped = unwrapLongitudes([ + { lon: 179.9, lat: 0 }, + { lon: -179.9, lat: 0 }, + ]); + assert.equal(unwrapped[0].lon, 179.9); + assert.ok( + Math.abs(unwrapped[1].lon - 180.1) < 1e-9, + `continuous neighbour, got ${unwrapped[1].lon}`, + ); + assert.equal(wrapLongitude(180.1).toFixed(4), (-179.9).toFixed(4)); + assert.equal(wrapLongitude(-190), 170); + assert.equal(wrapLongitude(10), 10); + assert.equal(wrapLongitude(180), -180); + assert.equal(unwrapLongitudes([]).length, 0); +}); diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js new file mode 100644 index 000000000..7f164b54f --- /dev/null +++ b/src/annotations/drawTool.js @@ -0,0 +1,546 @@ +/** + * 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 { pickWorldFromScreen } from './annotationResolver.js'; +import { + claimPointer, + pointerOwner, + releasePointer, +} from '../data/inputOwnership.js'; +import { + DRAW_SHAPES, + MAX_VERTICES, + addVertex, + createDrawSession, + drawHint, + finishReason, + finishSpec, + normalizeShape, + removeLastVertex, +} from './drawMode.js'; + +/** The id this tool claims the pointer under. */ +export 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]); + }; + + 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( + `That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`, + ); + else if (reason === 'invalid') + setHint('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('That shape could not be placed.'); + return result; + } catch (error) { + if (destroyed || attempt !== generation) return null; + setHint(`Could not place the shape: ${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('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(`${pointerOwner()} is using the pointer — close it first.`); + 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]') || []), + ]; + for (const btn of shapeButtons) { + listen(btn, 'click', () => { + shape = normalizeShape(btn.dataset.shape); + for (const other of shapeButtons) { + const on = other === btn; + other.classList.toggle('active', on); + other.setAttribute('aria-checked', String(on)); + } + 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; + 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; +} diff --git a/src/annotations/drawTool.test.mjs b/src/annotations/drawTool.test.mjs new file mode 100644 index 000000000..e6ce2e9f3 --- /dev/null +++ b/src/annotations/drawTool.test.mjs @@ -0,0 +1,218 @@ +// STRUCTURAL contracts only: where files live, which ids the markup carries, +// what the docs may claim. Things a running test cannot observe. +// +// Everything about the tool's BEHAVIOUR — the pointer lease, borrowing and +// restoring the viewer's own click actions, teardown, the Enter key, preview +// heights, Clear — is asserted against the running module in +// `drawToolBehaviour.test.mjs`, not by reading this file's source. +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'); +const tool = () => read('src/annotations/drawTool.js'); + +test('the application composition wires the draw tool and owns its teardown', () => { + const tools = read('src/app/tools.js'); + const engineAt = tools.indexOf('const annotations = initAnnotations({'); + const drawAt = tools.indexOf( + 'const drawTool = initDrawTool({ viewer, annotations });', + ); + assert.ok(engineAt >= 0, 'initAnnotations call missing'); + assert.ok( + drawAt > engineAt, + 'initDrawTool must follow initAnnotations and receive its engine', + ); + assert.match( + tools, + /import \{ initDrawTool \} from '\.\.\/annotations\/drawTool\.js';/, + ); + // The tool holds a pointer claim, DOM listeners and a data source, so the + // application lifetime disposes it — not whoever last pressed the button. + const deferAt = tools.indexOf('defer(() => drawTool?.destroy());'); + assert.ok(deferAt > drawAt, 'the shell must defer the draw tool teardown'); +}); + +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', () => { + // The DISPLAY rail is a component template now, assembled into index.html at + // build time — the markup is not in index.html any more. + const html = read('src/ui/templates/display-controls.html'); + const ids = [ + 'draw-toggle', + 'draw-mode-row', + 'draw-label-row', + 'draw-label-input', + 'draw-color-select', + 'draw-clear', + 'draw-hint', + ]; + for (const id of ids) { + 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`, + ); + } + for (const id of ids) { + assert.match( + tool(), + new RegExp(`getElementById\\('${id}'\\)`), + `drawTool must bind #${id}`, + ); + } +}); + +test('the DISPLAY rail styles live in their current owner, not the shim', () => { + // The PR this came from was written against the pre-refactor layout, where + // these rules lived in the root style.css. That file is an import shim now, + // and putting rules back into it would silently undo the split. + const shim = read('style.css'); + assert.ok( + shim.split('\n').length < 40, + 'style.css is an import shim, not a stylesheet', + ); + assert.doesNotMatch( + shim, + /draw-hint|pp-text-input/, + 'draw styles belong to a component stylesheet', + ); + const controls = read('src/ui/styles/controls.css'); + for (const rule of [ + '#draw-mode-row', + '#draw-label-row', + '.pp-text-input', + '.draw-clear-btn', + '.draw-hint', + 'body.gev-drawing', + ]) { + assert.ok( + controls.includes(rule), + `${rule} must live in src/ui/styles/controls.css`, + ); + } +}); + +test('nothing promises a GeoJSON export for drawn shapes', () => { + for (const file of [ + 'src/annotations/drawTool.js', + 'src/annotations/drawMode.js', + ]) { + assert.doesNotMatch( + read(file), + /GeoJSON/i, + `${file} still claims a GeoJSON export`, + ); + } + for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) { + const source = read(file); + const at = source.indexOf('DISPLAY ▸ **Draw**'); + const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw'); + assert.ok(start >= 0, `${file} should describe the Draw control`); + const section = source.slice(start, start + 1200); + assert.doesNotMatch( + section, + /GeoJSON/i, + `${file} still claims a GeoJSON export for drawn shapes`, + ); + } +}); + +test('the docs do not promise a vertex height the renderer would discard', () => { + // Areas and routes are DRAPED, so a per-vertex height is not a placement. + // That the finished spec actually drops it is asserted against the running + // module in drawToolBehaviour.test.mjs; this only guards the wording. + assert.doesNotMatch(tool(), /lands on the roof/); + assert.doesNotMatch( + read('docs/CURRENT-STATE.md'), + /a vertex on a roof lands on the roof/, + ); +}); + +test('draped marks classify onto terrain as well as 3D tiles', () => { + // CESIUM_3D_TILE alone rendered nothing on a keyless boot, where Cesium's own + // globe carries the imagery and there are no tiles to classify against. The + // rendered proof is in scripts/qa-draw-tool.mjs, which counts stroke pixels + // against a keyless server; this stops the constant being narrowed again. + const renderer = read('src/annotations/worldAnnotationRenderer.js'); + assert.match(renderer, /const CLASSIFY = Cesium\.ClassificationType\.BOTH;/); + assert.doesNotMatch( + renderer, + /classificationType: Cesium\.ClassificationType\.CESIUM_3D_TILE/, + ); +}); + +test('the draw modules are registered for formatting and boundary checks', () => { + const formatScope = JSON.parse(read('scripts/format-scope.json')); + for (const file of [ + 'src/annotations/drawMode.js', + 'src/annotations/drawMode.test.mjs', + 'src/annotations/drawTool.js', + 'src/annotations/drawTool.test.mjs', + 'src/data/inputOwnership.js', + ]) { + assert.ok( + formatScope.includes(file), + `${file} must be in the formatting scope`, + ); + } + const boundaries = JSON.parse(read('scripts/package-boundaries.json')); + const owners = (file) => + Object.entries(boundaries) + .filter(([, group]) => group.modules?.includes(file)) + .map(([name]) => name); + for (const file of [ + 'src/annotations/drawMode.js', + 'src/annotations/drawTool.js', + 'src/data/inputOwnership.js', + ]) { + assert.ok( + owners(file).length > 0, + `${file} must be owned by at least one boundary group`, + ); + } + // Every layer that consults the shared claim has to own the module it reads, + // or its bundle fails the boundary check. + for (const group of ['alpr-cameras', 'vessel-layer', 'satellites-layer']) { + assert.ok( + boundaries[group]?.modules.includes('src/data/inputOwnership.js'), + `${group} consults the pointer claim and must own the module`, + ); + } + const exports = JSON.parse(read('package.json')).exports; + assert.equal(exports['./annotations'], './src/annotations/index.js'); +}); diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs new file mode 100644 index 000000000..6da4c91af --- /dev/null +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -0,0 +1,467 @@ +// Behavioural tests for the draw tool: the module is driven against fake DOM +// and viewer doubles and its OBSERVABLE effects are asserted — which lease it +// holds, which viewer input actions it borrowed and gave back, what it leaves +// behind when destroyed. Nothing here reads the module's source text. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { initDrawTool } from './drawTool.js'; +import { + isPointerFree, + pointerOwner, + claimPointer, + releasePointer, + resetPointerOwnership, +} from '../data/inputOwnership.js'; + +/* ── DOM doubles ───────────────────────────────────────────────────────── */ + +class FakeClassList { + constructor() { + this.names = new Set(); + } + add(...names) { + for (const n of names) this.names.add(n); + } + remove(...names) { + for (const n of names) this.names.delete(n); + } + contains(name) { + return this.names.has(name); + } + toggle(name, force) { + const on = force === undefined ? !this.names.has(name) : Boolean(force); + if (on) this.names.add(name); + else this.names.delete(name); + return on; + } +} + +class FakeElement { + constructor(tagName = 'DIV', id = '') { + this.tagName = tagName.toUpperCase(); + this.id = id; + this.classList = new FakeClassList(); + this.dataset = {}; + this.attributes = new Map(); + this.textContent = ''; + this.value = ''; + this.children = []; + this.listeners = []; + } + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + getAttribute(name) { + return this.attributes.get(name) ?? null; + } + addEventListener(type, listener, options) { + this.listeners.push({ type, listener, options }); + } + removeEventListener(type, listener) { + const at = this.listeners.findIndex( + (entry) => entry.type === type && entry.listener === listener, + ); + if (at >= 0) this.listeners.splice(at, 1); + } + querySelectorAll(selector) { + if (selector === '.pp-mode-btn[data-shape]') + return this.children.filter((child) => child.dataset.shape); + const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1]; + if (shape) return this.children.filter((child) => child.dataset.shape === shape); + return []; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } + click() { + return this.emit('click'); + } + closest(selector) { + // Only what the tool asks: "am I inside an activatable control?" + const activatable = ['button', 'a', 'select', '[role="button"]', '[role="radio"]']; + if (!activatable.some((part) => selector.includes(part))) return null; + return this.tagName === 'BUTTON' || this.tagName === 'A' || this.tagName === 'SELECT' + ? this + : null; + } + /** Deliver an event to this element's own listeners. */ + emit(type, event = {}) { + const payload = { + type, + target: this, + preventDefault() {}, + stopImmediatePropagation() {}, + ...event, + }; + for (const entry of [...this.listeners]) + if (entry.type === type) entry.listener(payload); + return payload; + } +} + +function fakeDom() { + const byId = new Map(); + const make = (id, tagName = 'DIV') => { + const element = new FakeElement(tagName, id); + byId.set(id, element); + return element; + }; + make('draw-toggle', 'BUTTON'); + const modeRow = make('draw-mode-row'); + for (const shape of ['area', 'line', 'pin']) { + const button = new FakeElement('BUTTON'); + button.dataset.shape = shape; + modeRow.children.push(button); + } + make('draw-label-row'); + make('draw-label-input', 'INPUT'); + make('draw-color-select', 'SELECT'); + make('draw-clear', 'BUTTON'); + make('draw-hint', 'SPAN'); + + const body = new FakeElement('BODY'); + const documentDouble = { + body, + getElementById: (id) => byId.get(id) ?? null, + listeners: [], + addEventListener(type, listener, options) { + this.listeners.push({ type, listener, options }); + }, + removeEventListener(type, listener) { + const at = this.listeners.findIndex( + (entry) => entry.type === type && entry.listener === listener, + ); + if (at >= 0) this.listeners.splice(at, 1); + }, + emit(type, event = {}) { + const payload = { + type, + target: body, + preventDefault() {}, + stopImmediatePropagation() {}, + ...event, + }; + for (const entry of [...this.listeners]) + if (entry.type === type) entry.listener(payload); + return payload; + }, + }; + return { byId, documentDouble }; +} + +/* ── viewer double ─────────────────────────────────────────────────────── */ + +function fakeViewer() { + const canvas = new FakeElement('CANVAS'); + canvas.clientWidth = 800; + canvas.clientHeight = 600; + const actions = new Map(); + const stockHandler = { + getInputAction: (type) => actions.get(type), + setInputAction: (action, type) => actions.set(type, action), + removeInputAction: (type) => actions.delete(type), + }; + const attached = []; + const pendingAdds = []; + const dataSources = { + get length() { + return attached.length; + }, + get: (index) => attached[index], + add(source) { + // Cesium resolves this on a later tick — the whole point of finding 7. + const promise = Promise.resolve().then(() => { + attached.push(source); + return source; + }); + pendingAdds.push(promise); + return promise; + }, + remove(source) { + const at = attached.indexOf(source); + if (at >= 0) attached.splice(at, 1); + return at >= 0; + }, + }; + return { + scene: { canvas, requestRender() {} }, + screenSpaceEventHandler: stockHandler, + dataSources, + _attached: attached, + _actions: actions, + }; +} + +function fakeAnnotations() { + const calls = { annotate: [], clear: 0 }; + return { + calls, + async annotate(specs, options) { + calls.annotate.push({ specs, options }); + return { drawn: specs.length }; + }, + clear() { + calls.clear += 1; + }, + }; +} + +/** Stand up a tool against fresh doubles, with globals restored afterwards. */ +function harness() { + resetPointerOwnership(); + const { byId, documentDouble } = fakeDom(); + const viewer = fakeViewer(); + const annotations = fakeAnnotations(); + const previousDocument = globalThis.document; + const previousWindow = globalThis.window; + globalThis.document = documentDouble; + globalThis.window = globalThis.window || {}; + const tool = initDrawTool({ viewer, annotations }); + return { + tool, + viewer, + annotations, + byId, + documentDouble, + element: (id) => byId.get(id), + restore() { + resetPointerOwnership(); + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }, + }; +} + +const LEFT_CLICK = Cesium.ScreenSpaceEventType.LEFT_CLICK; +const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; + +/* ── the tests ─────────────────────────────────────────────────────────── */ + +test('turning Draw on takes a lease and turning it off gives it back', async () => { + const h = harness(); + try { + assert.equal(isPointerFree(), true); + h.element('draw-toggle').emit('click'); + assert.equal(pointerOwner(), 'draw', 'the session owns the pointer'); + assert.equal(h.tool.active, true); + + h.element('draw-toggle').emit('click'); + assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer'); + assert.equal(h.tool.active, false); + } finally { + h.restore(); + } +}); + +test('Draw refuses to start when something else holds the pointer', async () => { + const h = harness(); + try { + const other = claimPointer('directions'); + h.element('draw-toggle').emit('click'); + assert.equal(h.tool.active, false, 'it must not half-start'); + assert.equal(pointerOwner(), 'directions', 'and must not steal the claim'); + assert.match(h.element('draw-hint').textContent, /directions/); + assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound'); + + releasePointer(other); + h.element('draw-toggle').emit('click'); + assert.equal(h.tool.active, true, 'and it starts once the pointer is free'); + } finally { + h.restore(); + } +}); + +test('a destroyed instance cannot free the pointer its replacement holds', async () => { + const first = harness(); + try { + first.element('draw-toggle').emit('click'); + assert.equal(pointerOwner(), 'draw'); + // The replacement takes over before the old one finishes tearing down. + first.tool.setActive(false); + const second = harness(); + try { + second.element('draw-toggle').emit('click'); + assert.equal(pointerOwner(), 'draw'); + await first.tool.destroy(); + assert.equal( + pointerOwner(), + 'draw', + 'the live session still owns the pointer after the old one is destroyed', + ); + assert.equal(second.tool.active, true); + } finally { + second.restore(); + } + } finally { + first.restore(); + } +}); + +test('both stock viewer click actions are borrowed for the session and given back', async () => { + const h = harness(); + try { + const stockClick = () => 'viewer picks and selects'; + const stockDouble = () => 'viewer tracks'; + h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK); + h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK); + + h.element('draw-toggle').emit('click'); + assert.equal( + h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), + undefined, + "the viewer's own selecting click must not run while drawing", + ); + assert.equal( + h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK), + undefined, + ); + + h.element('draw-toggle').emit('click'); + assert.equal( + h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), + stockClick, + 'the exact function is restored, not a replacement', + ); + assert.equal( + h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK), + stockDouble, + ); + } finally { + h.restore(); + } +}); + +test('Enter finishes from the canvas and the label field, never from a focused button', async () => { + const h = harness(); + try { + h.element('draw-toggle').emit('click'); + h.tool.setShape('area'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.tool.addVertex(-97.74, 30.27); + + // Enter while the Clear button has keyboard focus means "press Clear". + h.documentDouble.emit('keydown', { key: 'Enter', target: h.element('draw-clear') }); + await Promise.resolve(); + assert.equal( + h.annotations.calls.annotate.length, + 0, + 'a focused button keeps its own Enter', + ); + assert.equal(h.tool.session.vertices.length, 3, 'and the shape is untouched'); + + // Enter from the page body (the canvas has focus in practice) finishes it. + h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the map finishes'); + assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area'); + } finally { + h.restore(); + } +}); + +test('the live preview hangs at the height the click landed on', async () => { + const h = harness(); + try { + h.element('draw-toggle').emit('click'); + h.tool.setShape('line'); + h.tool.addVertex(-97.75, 30.26, 187.5); + h.tool.addVertex(-97.74, 30.26, 212.25); + + await h.tool.whenSettled(); + const source = h.viewer._attached[0] ?? null; + assert.ok(source, 'the preview data source is attached'); + const points = source.entities.values.filter((entity) => entity.point); + assert.equal(points.length, 2, 'one preview dot per vertex'); + const heights = points.map((entity) => { + const position = entity.position.getValue(Cesium.JulianDate.now()); + return Cesium.Cartographic.fromCartesian(position).height; + }); + assert.ok(Math.abs(heights[0] - 187.5) < 0.5, `got ${heights[0]}`); + assert.ok(Math.abs(heights[1] - 212.25) < 0.5, `got ${heights[1]}`); + + // …and the finished shape is still flattened, deliberately: the renderer + // drapes, so a height carried further would be discarded out of sight. + h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body }); + await Promise.resolve(); + await Promise.resolve(); + const spec = h.annotations.calls.annotate[0].specs[0]; + assert.deepEqual(spec.path, [ + [-97.75, 30.26], + [-97.74, 30.26], + ]); + } finally { + h.restore(); + } +}); + +test('destroy in the same tick as init still removes the preview data source', async () => { + const h = harness(); + try { + // Nothing has been attached yet — dataSources.add() resolves later. + assert.equal(h.viewer._attached.length, 0, 'the add is still pending'); + await h.tool.destroy(); + await Promise.resolve(); + assert.equal( + h.viewer._attached.length, + 0, + 'the preview must not be left attached behind the teardown', + ); + } finally { + h.restore(); + } +}); + +test('destroy releases the pointer, the handler, the listeners and the stock actions', async () => { + const h = harness(); + try { + const stockClick = () => 'viewer picks and selects'; + h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK); + h.element('draw-toggle').emit('click'); + h.tool.addVertex(-97.75, 30.26); + assert.equal(pointerOwner(), 'draw'); + assert.ok(h.tool.diagnostics().domListeners > 0); + + await h.tool.destroy(); + await Promise.resolve(); + + assert.equal(isPointerFree(), true, 'the pointer is free'); + const after = h.tool.diagnostics(); + assert.equal(after.destroyed, true); + assert.equal(after.sceneHandler, false, 'the Cesium handler is gone'); + assert.equal(after.domListeners, 0, 'every DOM listener came off'); + assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown'); + assert.equal(h.element('draw-toggle').listeners.length, 0); + assert.equal( + h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), + stockClick, + 'the viewer got its selecting click back', + ); + assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached'); + + // Idempotent: a second destroy is harmless. + await h.tool.destroy(); + } finally { + h.restore(); + } +}); + +test('Clear wipes the board and the shape in progress', async () => { + const h = harness(); + try { + h.element('draw-toggle').emit('click'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + assert.equal(h.tool.session.vertices.length, 2); + + h.element('draw-clear').emit('click'); + assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed'); + assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress'); + } finally { + h.restore(); + } +}); diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index a76c271e8..7f0807d83 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -2226,6 +2226,11 @@ export function createAnnotationResolver({ return { lat: Cesium.Math.toDegrees(carto.latitude), lon: Cesium.Math.toDegrees(carto.longitude), + // The height the pick actually landed on — a roof, a hillside, or 0 on + // the ellipsoid. Callers that only want a coordinate ignore it; the draw + // tool's live preview needs it, or the rubber band sinks to sea level + // while the pointer is on a hill. + height: Number.isFinite(carto.height) ? carto.height : 0, }; } @@ -2331,5 +2336,12 @@ export function createAnnotationResolver({ viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, + // Scene helpers, not resolution: the manual draw tool turns a click into a + // world point with the same depth-aware cascade the agent's pixel fallback + // uses, and the engine samples the surface under a hand-placed mark. Both + // are exposed so there is ONE pick cascade in the app, not a second one + // written beside it. + pickWorldFromScreen, + sampleGroundHeight, }; } diff --git a/src/annotations/worldAnnotationRenderer.js b/src/annotations/worldAnnotationRenderer.js index 235bf0a0c..64a7df241 100644 --- a/src/annotations/worldAnnotationRenderer.js +++ b/src/annotations/worldAnnotationRenderer.js @@ -30,7 +30,20 @@ const PALETTE = { red: '#ff6b6b', }; -const CLASSIFY = Cesium.ClassificationType.CESIUM_3D_TILE; +/** + * What a draped mark is allowed to paint onto. + * + * This was CESIUM_3D_TILE, which is right with Google's photoreal tiles under + * the camera and wrong everywhere else: on a keyless boot (Esri or OSM imagery + * on Cesium's own globe) there are no 3D tiles to classify against, so every + * area fill, area outline, route and arrow rendered to nothing. The labels + * showed and the geometry did not, which reads as a broken feature rather than + * an unsupported surface — and it hit spoken annotations exactly as hard as + * hand-drawn ones. + * + * BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds. + */ +const CLASSIFY = Cesium.ClassificationType.BOTH; const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND; export function createWorldAnnotationRenderer(viewer) { diff --git a/src/app/tools.js b/src/app/tools.js index 6384d1805..956a26bb6 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,5 +1,6 @@ import { SceneDirector } from '../scenes/director.js'; import { initAnnotations } from '../annotations/index.js'; +import { initDrawTool } from '../annotations/drawTool.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { @@ -39,6 +40,11 @@ export function createApplicationTools({ if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; annotations.destroy(); }); + // DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer + // while a session is open, so its teardown belongs to the application + // lifetime rather than to whoever last pressed the button. + const drawTool = initDrawTool({ viewer, annotations }); + defer(() => drawTool?.destroy()); if (startChrome) defer(startChrome({ loadingScreen, styleManager, dataManager, signal })); // Idle render governor: flips the scene into requestRenderMode whenever diff --git a/src/data/cctvGizmo.js b/src/data/cctvGizmo.js index 841cc2df3..a1ce70b16 100644 --- a/src/data/cctvGizmo.js +++ b/src/data/cctvGizmo.js @@ -24,6 +24,7 @@ * invariant is untouched. */ import * as Cesium from 'cesium'; +import { isPointerFree } from './inputOwnership.js'; // Grazing guard (spec §5): reject plane intersections when the view ray is // nearly parallel to the constraint plane — the hit point races to infinity @@ -506,6 +507,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en } handler.setInputAction((event) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the press. + if (!isPointerFree()) return; if (!enabled) return; const part = pickGizmoPart(event.position); debugLog('LEFT_DOWN', event.position, 'part:', part); diff --git a/src/data/inputOwnership.js b/src/data/inputOwnership.js new file mode 100644 index 000000000..4b6a2adea --- /dev/null +++ b/src/data/inputOwnership.js @@ -0,0 +1,104 @@ +/** + * Who owns the pointer over the globe. + * + * Most layers bind their own `ScreenSpaceEventHandler` to the same canvas and + * select whatever is under a left click. That is right when nothing else is + * going on, and wrong the moment a tool needs the pointer for itself: a draw + * session placing a vertex on top of an aircraft must not also track the + * aircraft, and the next such tool must not have to be added to a list inside + * every layer. + * + * So there is one claim, held by at most one owner at a time: + * + * - A TOOL that needs the pointer calls `claimPointer('draw')` when it turns on + * and gets back a LEASE — an opaque token. It releases with + * `releasePointer(lease)` when it turns off. It does not consult anyone; + * holding the claim is what makes it the owner. + * - An AMBIENT SELECTION HANDLER — anything that picks an entity because the + * user clicked — early-returns while `isPointerFree()` is false. It never + * claims. + * + * Claims do not stack and are never stolen: while one owner holds the pointer, + * a second `claimPointer` returns null rather than displacing it — and so does + * a second claim under the SAME name, because two live instances of one tool + * (an old one mid-teardown, its replacement already running) are two owners, + * not one. The lease is what tells them apart: releasing takes the token that + * was issued, so a superseded instance calling `releasePointer` with its own + * stale lease frees nothing and its successor keeps the pointer. + * + * Ownership is page-scoped, like the layers and handlers it arbitrates. + */ + +/** @typedef {{owner: string, id: number}} PointerLease */ + +/** @type {PointerLease|null} */ +let currentLease = null; +let nextLeaseId = 1; + +/** + * Take the pointer for `owner`. + * @param {string} owner Stable identifier for the claiming tool, e.g. 'draw'. + * @returns {PointerLease|null} The lease to release with, or null if the + * pointer is already held — including by another instance of the same tool. + */ +export function claimPointer(owner) { + const name = normalizeOwner(owner); + if (!name) return null; + if (currentLease !== null) return null; + currentLease = Object.freeze({ owner: name, id: nextLeaseId++ }); + return currentLease; +} + +/** + * Give the pointer back. Only the exact lease that took it can release it, so a + * superseded instance cannot free the claim its replacement now holds. + * @param {PointerLease|null} lease The token `claimPointer` returned. + * @returns {boolean} Whether this call released the claim. + */ +export function releasePointer(lease) { + if (!currentLease || !lease || lease.id !== currentLease.id) return false; + currentLease = null; + return true; +} + +/** @returns {string|null} The current owner's name, or null when free. */ +export function pointerOwner() { + return currentLease ? currentLease.owner : null; +} + +/** @returns {boolean} True when no tool holds the pointer. */ +export function isPointerFree() { + return currentLease === null; +} + +/** + * @param {string} owner + * @returns {boolean} True when a tool of that name is holding the pointer. + */ +export function isPointerOwnedBy(owner) { + const name = normalizeOwner(owner); + return Boolean(name) && pointerOwner() === name; +} + +/** + * @param {PointerLease|null} lease + * @returns {boolean} True when THIS lease is the live one. + */ +export function isLeaseCurrent(lease) { + return Boolean(currentLease && lease && lease.id === currentLease.id); +} + +/** + * Drop any claim. For tests and for application teardown, which cannot rely on + * a half-disposed tool to release its own lease. + * @returns {string|null} The owner that was holding it, if any. + */ +export function resetPointerOwnership() { + const previous = pointerOwner(); + currentLease = null; + return previous; +} + +function normalizeOwner(owner) { + return typeof owner === 'string' && owner.trim() ? owner.trim() : null; +} diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs new file mode 100644 index 000000000..9b20f3ad4 --- /dev/null +++ b/src/data/inputOwnership.test.mjs @@ -0,0 +1,176 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + claimPointer, + isLeaseCurrent, + isPointerFree, + isPointerOwnedBy, + pointerOwner, + releasePointer, + resetPointerOwnership, +} from './inputOwnership.js'; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); + +test.beforeEach(() => resetPointerOwnership()); + +test('the pointer starts free and a claim hands back a lease', () => { + assert.equal(isPointerFree(), true); + assert.equal(pointerOwner(), null); + + const lease = claimPointer('draw'); + assert.ok(lease, 'a successful claim returns a lease, not a boolean'); + assert.equal(isPointerFree(), false); + assert.equal(pointerOwner(), 'draw'); + assert.equal(isPointerOwnedBy('draw'), true); + assert.equal(isPointerOwnedBy('directions'), false); + assert.equal(isLeaseCurrent(lease), true); +}); + +test('a claim is never stolen — not even by the same tool name', () => { + const first = claimPointer('draw'); + assert.ok(first); + + assert.equal( + claimPointer('directions'), + null, + 'another tool must not displace it', + ); + assert.equal( + claimPointer('draw'), + null, + 'a SECOND instance of the same tool is a second owner, not a no-op', + ); + assert.equal(pointerOwner(), 'draw'); + assert.equal(isLeaseCurrent(first), true); +}); + +test('a replaced instance cannot free its successor’s claim', () => { + // The bug this rule exists for. An old Draw instance is torn down while a new + // one is already running; the old teardown calls releasePointer. Under + // release-by-name that freed the LIVE claim and every layer started selecting + // through the new instance's vertices. + const stale = claimPointer('draw'); + assert.ok(releasePointer(stale), 'the old instance gives the pointer back'); + + const live = claimPointer('draw'); + assert.ok(live, 'the replacement takes it'); + assert.notEqual(live.id, stale.id, 'a fresh claim is a fresh lease'); + + assert.equal(releasePointer(stale), false, 'the stale lease frees nothing'); + assert.equal(pointerOwner(), 'draw', 'the replacement still holds it'); + assert.equal(isLeaseCurrent(stale), false); + assert.equal(isLeaseCurrent(live), true); + + assert.equal(releasePointer(live), true); + assert.equal(isPointerFree(), true); +}); + +test('release ignores anything that is not the live lease', () => { + const lease = claimPointer('draw'); + for (const bad of [ + null, + undefined, + 'draw', + {}, + { owner: 'draw', id: lease.id + 99 }, + ]) { + assert.equal(releasePointer(bad), false, JSON.stringify(bad)); + assert.equal(pointerOwner(), 'draw'); + } + assert.equal(releasePointer(lease), true); + assert.equal(releasePointer(lease), false, 'releasing twice changes nothing'); +}); + +test('a claim needs a real owner id', () => { + for (const bad of ['', ' ', null, undefined, 7, {}]) { + assert.equal(claimPointer(bad), null, JSON.stringify(bad)); + assert.equal(isPointerFree(), true); + } + const lease = claimPointer(' draw '); + assert.ok(lease, 'ids are trimmed, not rejected for padding'); + assert.equal(pointerOwner(), 'draw'); + assert.equal(releasePointer(lease), true); +}); + +test('reset reports who was holding it, for teardown', () => { + assert.equal(resetPointerOwnership(), null); + const lease = claimPointer('draw'); + assert.equal(resetPointerOwnership(), 'draw'); + assert.equal(isPointerFree(), true); + assert.equal(isLeaseCurrent(lease), false); +}); + +test('every scene click handler consults ownership before it picks', () => { + // This is the whole point of a shared contract: a new tool must not have to + // find and edit each of these. If a layer grows a click handler it has to + // appear here too. + const guarded = [ + ['src/data/trackingClickGesture.js', 'onClick(click, gesture);'], + ['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'], + ['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'], + ['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'], + ['src/layers/bikeshare/selection.js', 'viewer.scene.pick(click.position)'], + ['src/layers/firms/selection.js', 'scene.pick(click.position)'], + [ + 'src/layers/installations/selection.js', + 'viewer.scene.pick(click.position)', + ], + ['src/layers/launches/lifecycle.js', 'drillPick(movement.position'], + ['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'], + [ + 'src/layers/satellites/interaction.js', + 'viewer.scene.pick(click.position)', + ], + [ + 'src/layers/submarineCables/interaction.js', + 'viewer.scene.pick(click.position)', + ], + ['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'], + ]; + for (const [file, firstPick] of guarded) { + const source = read(file); + assert.match( + source, + /import \{ isPointerFree \} from '[^']*inputOwnership\.js';/, + `${file} must consult the shared pointer claim`, + ); + const guardAt = source.indexOf('if (!isPointerFree()) return;'); + const pickAt = source.indexOf(firstPick); + assert.ok(guardAt >= 0, `${file} is missing the guard`); + assert.ok( + pickAt >= 0, + `${file}: could not find its pick call (${firstPick})`, + ); + assert.ok( + guardAt < pickAt, + `${file} must yield before it picks, not after`, + ); + } +}); + +test('ambient selection handlers never claim the pointer themselves', () => { + // Claiming from a selection handler would deadlock every other layer the + // moment a user clicked anything. Only tools claim. + for (const file of [ + 'src/data/trackingClickGesture.js', + 'src/data/localGeojsonCore.js', + 'src/data/cctvGizmo.js', + 'src/layers/vessels/selection.js', + 'src/layers/satellites/interaction.js', + ]) { + assert.doesNotMatch( + read(file), + /claimPointer\(/, + `${file} must not claim the pointer`, + ); + } +}); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 764005568..edb246fdf 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from './inputOwnership.js'; import { selectInfraLod, applyInfraEvictionGrace, @@ -768,6 +769,8 @@ export function createLocalGeoJsonLayer( viewer.scene.canvas, ); _clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js). + if (!isPointerFree()) return; if (!_enabled) return; const picked = viewer.scene.pick(click.position); diff --git a/src/data/trackingClickGesture.js b/src/data/trackingClickGesture.js index 67959bf98..d8bb4211d 100644 --- a/src/data/trackingClickGesture.js +++ b/src/data/trackingClickGesture.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from './inputOwnership.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 tool owns the pointer: a draw vertex placed over an aircraft is a + // vertex, not a track request (src/data/inputOwnership.js). + if (!isPointerFree()) return; onClick(click, gesture); }, eventTypes.LEFT_CLICK); } diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index f56787c79..508d923ed 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; import { LAYER_ID, MAX_VIEWPORT_DEGREES, @@ -376,6 +377,8 @@ export function createAlprPresentation({ state, services, source }) { viewer.scene.canvas, ); state.clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; if (!state.enabled) return; const picked = viewer.scene.pick(click.position); const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; diff --git a/src/layers/bikeshare/selection.js b/src/layers/bikeshare/selection.js index 74e540230..0c948281b 100644 --- a/src/layers/bikeshare/selection.js +++ b/src/layers/bikeshare/selection.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, @@ -153,6 +154,8 @@ export function createSelection({ viewer.scene.canvas, ); layerState._clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; const picked = viewer.scene.pick(click.position); if (picked) { diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index 4cec52c69..5665c615a 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -3,6 +3,7 @@ import { fireDetectionKey, } from '../../data/firmsLabels.js'; import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; import { CONTEXT_TOP_N } from './policy.js'; export function createSelection({ @@ -35,6 +36,8 @@ export function createSelection({ layerState._viewer, ); layerState._clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; const picked = layerState._viewer.scene.pick(click.position); const fire = pickedFire(picked); if (fire) { diff --git a/src/layers/installations/selection.js b/src/layers/installations/selection.js index ace9124cb..5599d5fdb 100644 --- a/src/layers/installations/selection.js +++ b/src/layers/installations/selection.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; import { LAYER_ID } from './policy.js'; export function createSelection({ @@ -24,6 +25,8 @@ export function createSelection({ viewer.scene.canvas, ); layerState.clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; if (!layerState.enabled) return; const picked = viewer.scene.pick(click.position); const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 2eea788d0..58d70d34b 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; export function createLifecycle({ state: layerState, @@ -26,6 +27,8 @@ export function createLifecycle({ viewer.scene.canvas, ); layerState._clickHandler.setInputAction((movement) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; if (!layerState._enabled || !layerState._dataSource?.show) return; const entity = viewer.scene .drillPick(movement.position, 12) diff --git a/src/layers/radio/interaction.js b/src/layers/radio/interaction.js index da8ff558a..11a22b9dd 100644 --- a/src/layers/radio/interaction.js +++ b/src/layers/radio/interaction.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; import { RADIO_PREFIX, RADIO_PICK_OFFSETS, @@ -117,6 +118,8 @@ export function createInteraction({ layerState._viewer.scene.canvas, ); layerState._clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; if (!radioPresentationAllowed()) return; const stationId = pickedRadioStationAt(click.position); if (!stationId) return; diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index 5944beaa5..7ef8816c6 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; export function createInteraction({ state: layerState, @@ -46,6 +47,8 @@ export function createInteraction({ viewer.scene.canvas, ); layerState._clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; if (!layerState._enabled) return; const picked = viewer.scene.pick(click.position); diff --git a/src/layers/submarineCables/interaction.js b/src/layers/submarineCables/interaction.js index 08e3384ed..aaa4f8aad 100644 --- a/src/layers/submarineCables/interaction.js +++ b/src/layers/submarineCables/interaction.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; export function createInteraction({ state, screenSpaceEventHandlerFactory }) { function registerPickEntity(entity, info) { @@ -10,6 +11,8 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) { if (state._clickHandler) return; state._clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas); state._clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; if (!state._enabled) return; const picked = viewer.scene.pick(click.position); const record = resolvePickRecord(picked); diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 56c59a16b..c7696e63c 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; export function createSelection({ @@ -33,6 +34,8 @@ export function createSelection({ function bindVesselInteraction(viewer, handler, keyTarget) { state.clickHandler = handler; handler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; if (!state.feed.enabled) return; const picked = viewer.scene.pick(click.position); const pickedId = resolvePickId(picked); diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 9e3e71bb6..c86be98f1 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -822,6 +822,52 @@ 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-clear-btn { + flex: 0 0 auto; + padding: 0 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; +} +.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; diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index 5ed677b5b..6bc9068f5 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -89,6 +89,32 @@ 11% +
+ +
+ Shape +
+ + + +
+
+
+ + + + +
+