Keep Draw rows in place across board changes, and frame lines across the antimeridian

The shape list rebuilt every row on each board change. A name being typed was
lost when a voice mark landed or expired, and a click right after typing only
committed the name, because the button under the pointer was replaced. Rows
are now updated in place, a re-entrant render is deferred, picking a colour or
Esc returns focus to the swatch, and keys on the list no longer reach Draw.

Fly-to on a line across the antimeridian flew to longitude 0. A drawn mark
and a voice mark are no longer deduplicated into one another, so a voice TTL
cannot expire a drawn pin, and a new pin on a hidden one is drawn.

The engine tests now check effects (renderer calls, alpha, notifications on
clear and expiry, the framed centre), not only return values.
This commit is contained in:
Said Turksever committed 2026-10-30 15:23:56 +00:00
1 parent 94470ff00e
commit bdbbc3d767
6 files changed
+428 -108

No files matched your search

+168
View File
@@ -12,6 +12,7 @@ import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import * as Cesium from 'cesium';
import assert from 'node:assert/strict';
import {
createAnnotationEngine,
@@ -787,6 +788,173 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
assert.equal(changes, seen, 'an unsubscribed listener hears nothing');
});
// The list trusts these calls to change the globe, not just the list: each
// is checked by its effect (renderer calls, alpha, camera, notifications).
test('hand-drawn marks: remove, hide, clear, expiry and framing have their effect', async (t) => {
const frames = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
globalThis.cancelAnimationFrame = () => {};
let now = 1_000_000;
t.mock.method(performance, 'now', () => now);
_resetRenderGovernorForTest();
t.after(() => {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
_resetRenderGovernorForTest();
});
const runFrames = () => {
for (let i = 0; i < 5 && frames.length; i += 1) frames.shift()();
};
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: {
camera: {
heading: 0,
flyToBoundingSphere: (sphere) => flights.push(sphere),
},
},
renderer,
});
let changes = 0;
engine.onChange(() => {
changes += 1;
});
await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
{
type: 'pin',
manual: true,
latitude: 30.3,
longitude: -97.7,
label: 'Base',
},
],
{ persist: true },
);
now += 1000;
runFrames();
const [camp, base] = engine.list();
assert.equal(camp.alpha, 1);
engine.setHidden(camp.id, true);
runFrames();
assert.equal(camp.alpha, 0, 'a hidden mark is drawn at alpha 0');
assert.ok(engine.list().includes(camp), 'and stays on the board');
engine.setHidden(camp.id, false);
runFrames();
assert.equal(camp.alpha, 1, 'showing it again is immediate, with no fade-in');
const removes = calls.remove;
let seen = changes;
engine.remove(base.id);
assert.equal(calls.remove, removes + 1, 'remove takes it off the globe');
assert.equal(changes, seen + 1, 'and tells the list');
seen = changes;
engine.clear();
assert.equal(changes, seen + 1, 'a clear tells the list');
// A voice mark with a TTL: its expiry must reach the list too.
await engine.annotate(
[{ type: 'pin', latitude: 1, longitude: 1, label: 'Brief' }],
{
persist: false,
ttlMs: 2000,
},
);
seen = changes;
now += 60_000;
runFrames();
assert.equal(engine.list().length, 0, 'the mark expired');
assert.equal(changes, seen + 1, 'expiry tells the list');
// A line across the antimeridian is framed there, not on the far side.
await engine.annotate(
[
{
type: 'route',
manual: true,
path: [
[179.9, -17],
[-179.9, -17.1],
],
label: '',
},
],
{ persist: true },
);
const [line] = engine.list();
engine.frame(line.id);
const centre = Cesium.Cartographic.fromCartesian(flights.at(-1).center);
assert.ok(
Math.abs(Math.abs(Cesium.Math.toDegrees(centre.longitude)) - 180) < 0.5,
`framed at ${Cesium.Math.toDegrees(centre.longitude)}°, not near 180°`,
);
});
test('hand-drawn marks: a voice mark or a hidden mark is not taken as their duplicate', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
],
{ persist: true },
);
const [drawn] = engine.list();
// The agent marks the same spot with the same label, briefly.
await engine.annotate(
[{ type: 'pin', latitude: 30.27, longitude: -97.74, label: 'Camp' }],
{
persist: false,
},
);
assert.equal(engine.list().length, 2, 'the voice mark is its own mark');
assert.equal(drawn.ttlMs, null, 'the drawn pin keeps persisting');
assert.equal(drawn.manual, true);
engine.setHidden(drawn.id, true);
const again = await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
],
{ persist: true },
);
assert.equal(again.drawn, 1);
const visible = engine.list().filter((mark) => mark.manual && !mark.hidden);
assert.equal(
visible.length,
1,
'a pin dropped on a hidden one is a new, visible pin',
);
});
test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();