mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(annotations): draw on the world by hand (#547)
Supersedes #235 by @cora-fresh-labs, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Future work (not in this PR): hand-drawn marks do not persist across a reload today, and neither do spoken annotations; there is no storage layer for marks. The natural home is Scenes: a drawing that belongs to a shot and persists per shot, so a scene animation can carry its own marks. Needs an owner. Co-authored-by: Clint Borle <[email protected]>
This commit is contained in:
1 parent
409e27d7f9
commit
9fc8d1966b
32 files changed
+3313
-19
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -89,6 +89,32 @@
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
||||
<span class="pp-label">Draw</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="draw-mode-row">
|
||||
<span class="pp-slider-mini-label">Shape</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw">
|
||||
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true">Area</button>
|
||||
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false">Line</button>
|
||||
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false">Pin</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row" id="draw-label-row">
|
||||
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" autocomplete="off" spellcheck="false">
|
||||
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape">
|
||||
<option value="primary">Primary</option>
|
||||
<option value="amber">Amber</option>
|
||||
<option value="cyan">Cyan</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="red">Red</option>
|
||||
</select>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board">Clear</button>
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
|
||||
Reference in new issue
Block a user