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:
Bilawal Sidhu authored and GitHub committed 2026-09-14 19:59:26 -05:00
1 parent 409e27d7f9
commit 9fc8d1966b
32 files changed
+3313 -19

No files matched your search

+3
View File
@@ -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);
+104
View File
@@ -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;
}
+176
View File
@@ -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`,
);
}
});
+3
View File
@@ -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);
+4
View File
@@ -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);
}