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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user