mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Release God's Eye View as open source
This commit is contained in:
1 parent
a5aaea7e16
commit
880a672b5e
433 files changed
+219893
-23
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,670 @@
|
||||
// Voice-annotation resilience contract tests — pure logic, no network, no browser.
|
||||
//
|
||||
// Locks the 2026-07-21 field-test fixes:
|
||||
// 1. A TRANSIENT (undefined) deferred-outline resolution is retried with backoff
|
||||
// (the /api/overpass proxy caches the late completion, so a retry is nearly
|
||||
// free) instead of silently leaving the mark a point forever. A DEFINITIVE
|
||||
// miss (null) is never retried — honest point beats hammering Overpass.
|
||||
// 2. targetKey normalization strips trailing locality qualifiers so "California"
|
||||
// and "California, United States" dedupe while their outlines are pending
|
||||
// (identity is GEOMETRY once resolved; targetKey is only the pending stand-in).
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createAnnotationEngine,
|
||||
resolveOutlineWithRetry,
|
||||
normalizeTargetKey,
|
||||
} from './annotationEngine.js';
|
||||
import { resolveAnnotationTarget } from './annotationResolver.js';
|
||||
import {
|
||||
getRenderGovernorDiagnostics,
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
// A resolver that replays a scripted sequence of outcomes (undefined = transient,
|
||||
// null = definitive miss, object = footprint) and counts its invocations.
|
||||
function scriptedResolver(outcomes) {
|
||||
const calls = { count: 0 };
|
||||
const resolve = async () => {
|
||||
const i = Math.min(calls.count, outcomes.length - 1);
|
||||
calls.count += 1;
|
||||
const out = outcomes[i];
|
||||
if (out instanceof Error) throw out;
|
||||
return out;
|
||||
};
|
||||
return { resolve, calls };
|
||||
}
|
||||
|
||||
// Instant fake wait that records the requested backoff delays.
|
||||
function fakeWait() {
|
||||
const delays = [];
|
||||
const waitFn = async (ms) => { delays.push(ms); };
|
||||
return { waitFn, delays };
|
||||
}
|
||||
|
||||
const FP = { ring: [[0, 0], [0, 1], [1, 1], [0, 0]], footprintKind: 'area' };
|
||||
|
||||
function installAnimationFrameStubs(t) {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalCancelAnimationFrame === undefined) delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
});
|
||||
}
|
||||
|
||||
function fakeRenderer() {
|
||||
const calls = { add: 0, update: 0, remove: 0 };
|
||||
return {
|
||||
calls,
|
||||
renderer: {
|
||||
add() { calls.add += 1; },
|
||||
update() { calls.update += 1; },
|
||||
remove() { calls.remove += 1; },
|
||||
sync() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushMicrotasks(rounds = 20) {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function httpFailure(status, retryAfter = null) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
headers: { get: (name) => (name.toLowerCase() === 'retry-after' ? retryAfter : null) },
|
||||
};
|
||||
}
|
||||
|
||||
test('retry: first-try footprint returns immediately, no retry, no waiting', async () => {
|
||||
const { resolve, calls } = scriptedResolver([FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
assert.equal(fp, FP);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
});
|
||||
|
||||
test('retry: a DEFINITIVE miss (null) is never retried — the honest point stands', async () => {
|
||||
const { resolve, calls } = scriptedResolver([null, FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
});
|
||||
|
||||
test('retry: a TRANSIENT miss (undefined) re-runs after the first backoff and can succeed', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
assert.equal(fp, FP);
|
||||
assert.equal(calls.count, 2);
|
||||
assert.deepEqual(delays, [8000]); // waited once, succeeded on attempt 2
|
||||
});
|
||||
|
||||
test('retry: a transient retry can still conclude with a definitive miss (no third run)', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, null]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 2);
|
||||
assert.deepEqual(delays, [8000]);
|
||||
});
|
||||
|
||||
test('retry: persistent transients exhaust the backoff schedule and give up as transient', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, undefined, undefined, FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
assert.equal(fp, undefined); // never found within budget — mark honestly stays a point
|
||||
assert.equal(calls.count, 3); // initial + one per backoff entry
|
||||
assert.deepEqual(delays, [8000, 25000]);
|
||||
});
|
||||
|
||||
test('retry: a stale board (clear/supersede during backoff) stops retrying immediately', async () => {
|
||||
const { resolve, calls } = scriptedResolver([undefined, FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
let stale = false;
|
||||
const fp = await resolveOutlineWithRetry(resolve, {
|
||||
delaysMs: [8000, 25000],
|
||||
waitFn: async (ms) => { await waitFn(ms); stale = true; }, // goes stale mid-wait
|
||||
isStale: () => stale,
|
||||
});
|
||||
assert.equal(fp, undefined);
|
||||
assert.equal(calls.count, 1); // never re-ran against a superseded board
|
||||
assert.deepEqual(delays, [8000]);
|
||||
});
|
||||
|
||||
test('retry: a thrown resolver is a definitive miss (no retry), matching the old catch→point path', async () => {
|
||||
const { resolve, calls } = scriptedResolver([new Error('boom'), FP]);
|
||||
const { waitFn, delays } = fakeWait();
|
||||
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
|
||||
assert.equal(fp, null);
|
||||
assert.equal(calls.count, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
});
|
||||
|
||||
test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const started = [];
|
||||
const releases = new Map();
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const resolveTarget = async ({ target }) => {
|
||||
const index = Number(target.slice('queue-'.length));
|
||||
return {
|
||||
lon: index,
|
||||
lat: index,
|
||||
height: 0,
|
||||
label: target,
|
||||
source: 'fake',
|
||||
ring: null,
|
||||
resolveOutline: () => new Promise((resolve) => {
|
||||
started.push(target);
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
releases.set(target, () => {
|
||||
inFlight -= 1;
|
||||
resolve({
|
||||
ring: [[index, index], [index + 0.1, index], [index, index + 0.1], [index, index]],
|
||||
footprintKind: 'area',
|
||||
lat: index,
|
||||
lon: index,
|
||||
height: 0,
|
||||
});
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
|
||||
let completed = 0;
|
||||
const allCompleted = new Promise((resolve) => engine.onOutlineEvent(() => {
|
||||
completed += 1;
|
||||
if (completed === 8) resolve();
|
||||
}));
|
||||
|
||||
const result = await engine.annotate(Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'area',
|
||||
target: `queue-${i}`,
|
||||
footprint: true,
|
||||
})));
|
||||
|
||||
assert.equal(result.drawn, 8);
|
||||
assert.equal(calls.add, 8, 'all point renders land before queued outlines finish');
|
||||
assert.deepEqual(started, ['queue-0', 'queue-1']);
|
||||
for (let next = 2; next < 8; next += 1) {
|
||||
releases.get(`queue-${next - 2}`)();
|
||||
await flushMicrotasks();
|
||||
assert.equal(started[next], `queue-${next}`, 'queued upgrades start in ask order');
|
||||
}
|
||||
releases.get('queue-6')();
|
||||
releases.get('queue-7')();
|
||||
await allCompleted;
|
||||
|
||||
assert.equal(maxInFlight, 2);
|
||||
assert.equal(calls.update, 8);
|
||||
});
|
||||
|
||||
test('outline queue: clear drops queued-but-unstarted upgrades without a later fetch', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const { renderer } = fakeRenderer();
|
||||
let fetchesStarted = 0;
|
||||
const resolveTarget = async ({ target, signal }) => ({
|
||||
lon: Number(target.slice('clear-'.length)),
|
||||
lat: 10,
|
||||
height: 0,
|
||||
label: target,
|
||||
source: 'fake',
|
||||
ring: null,
|
||||
resolveOutline: () => new Promise((resolve) => {
|
||||
fetchesStarted += 1;
|
||||
const finish = () => resolve(undefined);
|
||||
if (signal.aborted) finish();
|
||||
else signal.addEventListener('abort', finish, { once: true });
|
||||
}),
|
||||
});
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
|
||||
|
||||
await engine.annotate(Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'area',
|
||||
target: `clear-${i}`,
|
||||
footprint: true,
|
||||
})));
|
||||
assert.equal(fetchesStarted, 2);
|
||||
|
||||
engine.clear();
|
||||
await flushMicrotasks();
|
||||
assert.equal(engine.count(), 0);
|
||||
assert.equal(fetchesStarted, 2, 'the six queued upgrades were dropped on clear');
|
||||
});
|
||||
|
||||
test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429 stops', async (t) => {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(429, '5');
|
||||
};
|
||||
t.after(() => {
|
||||
t.mock.timers.reset();
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: {},
|
||||
target: 'PT2 rate-limit fixture',
|
||||
latitude: 12.345,
|
||||
longitude: 67.89,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
entityKind: 'district',
|
||||
});
|
||||
let settled = false;
|
||||
const waits = [];
|
||||
const pending = resolveOutlineWithRetry(resolved.resolveOutline, {
|
||||
waitFn: (ms) => {
|
||||
waits.push(ms);
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
})
|
||||
.finally(() => { settled = true; });
|
||||
while (waits.length === 0) await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 1);
|
||||
assert.deepEqual(waits, [8000], 'the 8s ladder floor is longer than Retry-After: 5');
|
||||
|
||||
t.mock.timers.tick(7999);
|
||||
await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 1);
|
||||
t.mock.timers.tick(1);
|
||||
await flushMicrotasks();
|
||||
// Mutation hygiene: if 429 is accidentally collapsed into ordinary transient,
|
||||
// drain its forbidden second ladder timer before making the named assertions.
|
||||
if (waits.length > 1) {
|
||||
t.mock.timers.tick(waits[1]);
|
||||
await flushMicrotasks();
|
||||
}
|
||||
|
||||
assert.equal(requestTimes.length, 2);
|
||||
assert.ok(requestTimes[1] - requestTimes[0] >= 5000, 'Retry-After is a hard minimum');
|
||||
assert.equal(settled, true, 'a second 429 must not schedule the 25s ladder replay');
|
||||
assert.equal(await pending, undefined);
|
||||
});
|
||||
|
||||
test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladder', async (t) => {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(500);
|
||||
};
|
||||
t.after(() => {
|
||||
t.mock.timers.reset();
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: {},
|
||||
target: 'PT2 transient fixture',
|
||||
latitude: -12.345,
|
||||
longitude: -67.89,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
entityKind: 'district',
|
||||
});
|
||||
let settled = false;
|
||||
const waits = [];
|
||||
const pending = resolveOutlineWithRetry(resolved.resolveOutline, {
|
||||
waitFn: (ms) => {
|
||||
waits.push(ms);
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
})
|
||||
.finally(() => { settled = true; });
|
||||
while (waits.length === 0) await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 1);
|
||||
assert.deepEqual(waits, [8000]);
|
||||
|
||||
t.mock.timers.tick(8000);
|
||||
await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 2);
|
||||
assert.equal(settled, false);
|
||||
assert.deepEqual(waits, [8000, 25_000]);
|
||||
t.mock.timers.tick(24_999);
|
||||
await flushMicrotasks();
|
||||
assert.equal(requestTimes.length, 2);
|
||||
t.mock.timers.tick(1);
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(requestTimes.length, 3);
|
||||
assert.deepEqual(requestTimes.map((at) => at - requestTimes[0]), [0, 8000, 33_000]);
|
||||
assert.equal(settled, true);
|
||||
assert.equal(await pending, undefined);
|
||||
});
|
||||
|
||||
test('targetKey: trailing locality qualifiers are stripped so state names dedupe', () => {
|
||||
assert.equal(normalizeTargetKey('California'), 'california');
|
||||
assert.equal(normalizeTargetKey('California, United States'), 'california');
|
||||
assert.equal(
|
||||
normalizeTargetKey('California'),
|
||||
normalizeTargetKey('California, United States'),
|
||||
);
|
||||
});
|
||||
|
||||
test('targetKey: lowercases, trims, and keeps comma-free names intact', () => {
|
||||
assert.equal(normalizeTargetKey(' Texas State Capitol '), 'texas state capitol');
|
||||
assert.equal(normalizeTargetKey('Lady Bird Lake'), 'lady bird lake');
|
||||
});
|
||||
|
||||
test('targetKey: multi-qualifier names keep only the leading place name', () => {
|
||||
assert.equal(normalizeTargetKey('Sixth Street, Austin, TX'), 'sixth street');
|
||||
});
|
||||
|
||||
test('targetKey: empty / absent targets stay null (coord and pixel specs never pending-collapse)', () => {
|
||||
assert.equal(normalizeTargetKey(''), null);
|
||||
assert.equal(normalizeTargetKey(' '), null);
|
||||
assert.equal(normalizeTargetKey(null), null);
|
||||
assert.equal(normalizeTargetKey(undefined), null);
|
||||
});
|
||||
|
||||
test('outline upgrade updates the rendered element in place without remove/add', async (t) => {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
globalThis.window = {
|
||||
__GOOGLE_MAPS_API_KEY__: 'unit-test-key',
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
let overpassCall = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
return { json: async () => ({
|
||||
status: 'OK',
|
||||
results: [{
|
||||
formatted_address: 'FB-3 Engine Texas Fixture',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
address_components: [{
|
||||
long_name: 'FB-3 Engine Texas Fixture',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
}],
|
||||
geometry: { location: { lat: 31, lng: -99 } },
|
||||
}],
|
||||
}) };
|
||||
}
|
||||
assert.equal(String(url), '/api/overpass');
|
||||
overpassCall += 1;
|
||||
if (overpassCall === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ elements: [{
|
||||
type: 'area',
|
||||
id: 54321,
|
||||
tags: { name: 'FB-3 Engine Texas Fixture', admin_level: '4' },
|
||||
}] }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ elements: [{
|
||||
type: 'relation',
|
||||
geometry: [
|
||||
{ lon: -106, lat: 25 },
|
||||
{ lon: -93, lat: 25 },
|
||||
{ lon: -93, lat: 36 },
|
||||
{ lon: -106, lat: 36 },
|
||||
{ lon: -106, lat: 25 },
|
||||
],
|
||||
}] }),
|
||||
};
|
||||
};
|
||||
t.after(() => {
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalCancelAnimationFrame === undefined) delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calls = { add: 0, update: 0, remove: 0 };
|
||||
const elements = new Map();
|
||||
let originalElement = null;
|
||||
const renderer = {
|
||||
add(anno) {
|
||||
calls.add += 1;
|
||||
originalElement = { id: anno.id };
|
||||
elements.set(anno.id, originalElement);
|
||||
},
|
||||
update(anno) {
|
||||
calls.update += 1;
|
||||
assert.equal(elements.get(anno.id), originalElement);
|
||||
},
|
||||
remove() { calls.remove += 1; },
|
||||
sync() {},
|
||||
};
|
||||
const viewer = {};
|
||||
const engine = createAnnotationEngine({ viewer, renderer });
|
||||
const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve));
|
||||
|
||||
const result = await engine.annotate([{
|
||||
type: 'area',
|
||||
target: 'FB-3 Engine Texas Fixture',
|
||||
label: 'Texas',
|
||||
footprint: true,
|
||||
}]);
|
||||
await upgraded;
|
||||
|
||||
assert.equal(result.drawn, 1);
|
||||
assert.deepEqual(calls, { add: 1, update: 1, remove: 0 });
|
||||
assert.equal(elements.get(result.ids[0]), originalElement, 'rendered element identity survives');
|
||||
assert.deepEqual(engine.list()[0].anchor, { lon: -100.8, lat: 29.4, height: 0 });
|
||||
assert.equal(engine.list()[0].ring.length, 5, 'the existing data-level centroid/ring snap remains');
|
||||
});
|
||||
|
||||
// ── Renderer-throw rollback: no phantom mark, no permanent governor hold ──────
|
||||
//
|
||||
// The engine holds continuous render for exactly as long as a mark is live
|
||||
// (annotations.size > 0). A renderer/WebGL throw that leaves an entry in the
|
||||
// map therefore leaks a hold that NOTHING releases short of an explicit clear —
|
||||
// the idle governor is defeated for the rest of the session. The fresh path has
|
||||
// always guarded this; the duplicate-REPLACEMENT path did the renderer swap
|
||||
// outside the guard. (perf rebase 2026-08-17)
|
||||
|
||||
function throwingRendererHarness() {
|
||||
const calls = { add: 0, remove: 0 };
|
||||
let failNextAdd = false;
|
||||
return {
|
||||
calls,
|
||||
failAddOnce() { failNextAdd = true; },
|
||||
renderer: {
|
||||
add() {
|
||||
calls.add += 1;
|
||||
if (failNextAdd) { failNextAdd = false; throw new Error('WebGL context lost'); }
|
||||
},
|
||||
update() {},
|
||||
remove() { calls.remove += 1; },
|
||||
sync() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_POINT = { lon: -97.7431, lat: 30.2672, height: 0 };
|
||||
|
||||
test('duplicate-replacement: a renderer throw leaves no phantom mark and no leaked hold', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
|
||||
const { renderer, failAddOnce } = throwingRendererHarness();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
|
||||
const first = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
assert.equal(first.drawn, 1);
|
||||
assert.equal(engine.list().length, 1);
|
||||
assert.ok(
|
||||
getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'a live mark must hold continuous render',
|
||||
);
|
||||
|
||||
// Same place, same caption, NEW colour → the duplicate-REPLACEMENT branch,
|
||||
// which deletes the dup and inserts the replacement before touching the
|
||||
// renderer. (A bare point dedupes on LABEL — two different captions at one
|
||||
// spot are deliberately two marks — so a recolour is the way into this
|
||||
// branch for a point; see findDuplicate.)
|
||||
failAddOnce();
|
||||
const second = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
]);
|
||||
|
||||
assert.equal(second.drawn, 0, 'the failed swap is reported as a failure, not a draw');
|
||||
assert.equal(second.failed, 1);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation survives the failed swap');
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'the annotations hold must be released — a leak defeats the idle governor permanently',
|
||||
);
|
||||
});
|
||||
|
||||
test('fresh path: a renderer throw is rolled back the same way', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
|
||||
const { renderer, failAddOnce } = throwingRendererHarness();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
|
||||
failAddOnce();
|
||||
const result = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
|
||||
assert.equal(result.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation from a failed first add');
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'a never-rendered mark must not hold continuous render',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Rollback must also unwind PARTIAL renderer state (second review) ──────────
|
||||
//
|
||||
// The harness above throws on the FIRST statement of add(), so a rollback that
|
||||
// only deletes the engine's map entry looked complete. The real renderers build
|
||||
// a mark in stages — the hybrid adds world geometry before it records the route;
|
||||
// the screen renderer inserts its SVG group before its last projection pass — so
|
||||
// a throw part-way leaves live content the map rollback cannot reach. The next
|
||||
// annotate of the same geometry then stacks a fresh mark over that orphan.
|
||||
|
||||
/** A renderer that puts the mark on the board BEFORE the step that fails. */
|
||||
function partialStateRendererHarness() {
|
||||
const live = new Map(); // stands in for world entities / SVG groups
|
||||
let failNextAdd = false;
|
||||
return {
|
||||
live,
|
||||
failAddOnce() { failNextAdd = true; },
|
||||
renderer: {
|
||||
add(anno) {
|
||||
live.set(anno.id, anno); // partial renderer state exists NOW
|
||||
if (failNextAdd) { failNextAdd = false; throw new Error('WebGL context lost'); }
|
||||
},
|
||||
update() {},
|
||||
remove(anno) { live.delete(anno.id); },
|
||||
sync() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('fresh path: a throw AFTER partial renderer state leaves nothing on the board', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
|
||||
const { renderer, failAddOnce, live } = partialStateRendererHarness();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
|
||||
failAddOnce();
|
||||
const failed = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
assert.equal(failed.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation');
|
||||
assert.equal(live.size, 0, 'the rollback must release what the renderer had already created');
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'a never-rendered mark must not hold continuous render',
|
||||
);
|
||||
|
||||
// The real cost of an orphan: re-annotating the same place stacks a second
|
||||
// mark on top of the one nothing owns.
|
||||
const retry = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
assert.equal(retry.drawn, 1);
|
||||
assert.equal(live.size, 1, 'a re-annotate must draw ONE mark, not stack over an orphan');
|
||||
});
|
||||
|
||||
test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing on the board', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
|
||||
const { renderer, failAddOnce, live } = partialStateRendererHarness();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
|
||||
const first = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
assert.equal(first.drawn, 1);
|
||||
assert.equal(live.size, 1);
|
||||
|
||||
// Recolour → the duplicate-REPLACEMENT swap: remove(dup) lands, add(anno)
|
||||
// creates its state and then throws.
|
||||
failAddOnce();
|
||||
const swap = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
]);
|
||||
assert.equal(swap.drawn, 0);
|
||||
assert.equal(engine.list().length, 0, 'no phantom annotation survives the failed swap');
|
||||
assert.equal(live.size, 0, 'and no half-swapped mark survives on the board');
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'the annotations hold must be released',
|
||||
);
|
||||
|
||||
const retry = await engine.annotate([
|
||||
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
|
||||
]);
|
||||
assert.equal(retry.drawn, 1);
|
||||
assert.equal(live.size, 1, 'the recoloured mark redraws once, with no orphan underneath');
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @module annotationGeoJson
|
||||
* @description Pure conversion between the runtime annotation model and a GeoJSON
|
||||
* FeatureCollection — a durable, testable interchange format for saving, exporting,
|
||||
* importing, sharing, and fixture-testing annotations. It is NOT a renderer input:
|
||||
* the Cesium/SVG renderers keep consuming runtime annotation objects. This module is
|
||||
* deliberately Cesium-free and dependency-free so it can run in tests and any context.
|
||||
*
|
||||
* Mapping (one Feature per mark; project fields namespaced `gev:`):
|
||||
* pin | highlight | label -> Point (anchor)
|
||||
* arrow -> LineString (anchor -> to)
|
||||
* route -> LineString (the routed path)
|
||||
* area (with a ring) -> Polygon (the footprint ring; centroid in `gev:anchor`)
|
||||
* area (no ring) -> Point (degenerate area; renders as a marker)
|
||||
*
|
||||
* Coordinates are GeoJSON positions `[lon, lat]`, or `[lon, lat, height]` when a height
|
||||
* is present (anchor / route / arrow). Area ring vertices are 2D `[lon, lat]` (the runtime
|
||||
* ring has no per-vertex height). Conversions are LOSSLESS for the semantic fields; transient
|
||||
* render state (alpha/bornAt/createdAt/expiring) is intentionally NOT carried — an importer
|
||||
* re-initializes it.
|
||||
*/
|
||||
|
||||
const VALID_TYPES = new Set(['pin', 'highlight', 'label', 'arrow', 'route', 'area']);
|
||||
|
||||
/** {lon,lat,height?} -> GeoJSON position, or null if not a finite lon/lat. */
|
||||
function toPosition(p) {
|
||||
if (!p || !Number.isFinite(p.lon) || !Number.isFinite(p.lat)) return null;
|
||||
return Number.isFinite(p.height) ? [p.lon, p.lat, p.height] : [p.lon, p.lat];
|
||||
}
|
||||
|
||||
/** GeoJSON position -> {lon,lat,height?}, or null if malformed. */
|
||||
function fromPosition(c) {
|
||||
if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) return null;
|
||||
return Number.isFinite(c[2]) ? { lon: c[0], lat: c[1], height: c[2] } : { lon: c[0], lat: c[1] };
|
||||
}
|
||||
|
||||
/** Mean of a runtime ring (`[[lon,lat],...]`), used as a fallback area anchor on import. */
|
||||
function ringCentroid(ring) {
|
||||
if (!Array.isArray(ring) || ring.length === 0) return null;
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (const [lon, lat] of ring) { sx += lon; sy += lat; }
|
||||
return { lon: sx / ring.length, lat: sy / ring.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one runtime annotation object to a GeoJSON Feature.
|
||||
* @param {object} anno - A runtime annotation (as produced by the engine).
|
||||
* @returns {object|null} A GeoJSON Feature, or null if `anno` can't form a valid geometry.
|
||||
*/
|
||||
export function annotationToFeature(anno) {
|
||||
if (!anno || typeof anno !== 'object' || !VALID_TYPES.has(anno.type)) return null;
|
||||
const type = anno.type;
|
||||
const properties = {
|
||||
'gev:type': type,
|
||||
'gev:id': anno.id ?? null,
|
||||
'gev:label': anno.label ?? null,
|
||||
'gev:color': anno.color ?? null,
|
||||
'gev:ttlMs': anno.ttlMs ?? null,
|
||||
};
|
||||
let geometry = null;
|
||||
|
||||
if (type === 'route') {
|
||||
const coords = (Array.isArray(anno.path) ? anno.path : []).map(toPosition).filter(Boolean);
|
||||
if (coords.length < 2) return null;
|
||||
geometry = { type: 'LineString', coordinates: coords };
|
||||
properties['gev:mode'] = anno.mode ?? null;
|
||||
properties['gev:distanceM'] = anno.distanceM ?? null;
|
||||
properties['gev:durationS'] = anno.durationS ?? null;
|
||||
properties['gev:fallback'] = Boolean(anno.fallback);
|
||||
} else if (type === 'arrow') {
|
||||
const from = toPosition(anno.anchor);
|
||||
const to = toPosition(anno.to);
|
||||
if (!from || !to) return null;
|
||||
geometry = { type: 'LineString', coordinates: [from, to] };
|
||||
} else if (type === 'area' && Array.isArray(anno.ring) && anno.ring.length >= 3) {
|
||||
const ring = [];
|
||||
for (const pair of anno.ring) {
|
||||
if (!Array.isArray(pair) || !Number.isFinite(pair[0]) || !Number.isFinite(pair[1])) return null;
|
||||
ring.push([pair[0], pair[1]]);
|
||||
}
|
||||
// GeoJSON linear rings must be explicitly closed (first === last).
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([first[0], first[1]]);
|
||||
geometry = { type: 'Polygon', coordinates: [ring] };
|
||||
properties['gev:footprintKind'] = anno.footprintKind ?? null;
|
||||
properties['gev:buildingHeight'] = anno.buildingHeight ?? null;
|
||||
properties['gev:synthesized'] = Boolean(anno.synthesized);
|
||||
const anchor = toPosition(anno.anchor);
|
||||
if (anchor) properties['gev:anchor'] = anchor; // preserve the exact centroid, don't recompute
|
||||
} else {
|
||||
// pin / highlight / label, or a degenerate `area` with no ring -> a Point at the anchor.
|
||||
const anchor = toPosition(anno.anchor);
|
||||
if (!anchor) return null;
|
||||
geometry = { type: 'Point', coordinates: anchor };
|
||||
if (type === 'area') {
|
||||
properties['gev:footprintKind'] = anno.footprintKind ?? null;
|
||||
properties['gev:synthesized'] = Boolean(anno.synthesized);
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'Feature', geometry, properties };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a GeoJSON Feature back to a runtime annotation object (semantic fields only —
|
||||
* the importer adds render state). Fails CLOSED: returns null for any malformed feature,
|
||||
* unknown `gev:type`, or geometry that doesn't match the declared type.
|
||||
* @param {object} feature - A GeoJSON Feature produced by {@link annotationToFeature}.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function featureToAnnotation(feature) {
|
||||
if (!feature || feature.type !== 'Feature' || !feature.geometry || !feature.properties) return null;
|
||||
const g = feature.geometry;
|
||||
const p = feature.properties;
|
||||
const type = p['gev:type'];
|
||||
if (!VALID_TYPES.has(type)) return null;
|
||||
|
||||
const base = {
|
||||
type,
|
||||
id: p['gev:id'] ?? null,
|
||||
label: p['gev:label'] ?? null,
|
||||
color: p['gev:color'] ?? 'primary',
|
||||
ttlMs: p['gev:ttlMs'] ?? null,
|
||||
};
|
||||
|
||||
if (type === 'route') {
|
||||
if (g.type !== 'LineString' || !Array.isArray(g.coordinates) || g.coordinates.length < 2) return null;
|
||||
const path = g.coordinates.map(fromPosition);
|
||||
if (path.some((x) => !x)) return null;
|
||||
return {
|
||||
...base,
|
||||
anchor: path[0],
|
||||
to: null,
|
||||
ring: null,
|
||||
path,
|
||||
mode: p['gev:mode'] ?? null,
|
||||
distanceM: p['gev:distanceM'] ?? null,
|
||||
durationS: p['gev:durationS'] ?? null,
|
||||
fallback: Boolean(p['gev:fallback']),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'arrow') {
|
||||
if (g.type !== 'LineString' || !Array.isArray(g.coordinates) || g.coordinates.length !== 2) return null;
|
||||
const from = fromPosition(g.coordinates[0]);
|
||||
const to = fromPosition(g.coordinates[1]);
|
||||
if (!from || !to) return null;
|
||||
return { ...base, anchor: from, to, ring: null };
|
||||
}
|
||||
|
||||
if (g.type === 'Polygon') {
|
||||
if (type !== 'area' || !Array.isArray(g.coordinates) || !Array.isArray(g.coordinates[0])) return null;
|
||||
const raw = g.coordinates[0];
|
||||
if (raw.length < 4) return null; // a closed triangle is the minimum (4 positions)
|
||||
const ring = [];
|
||||
for (const c of raw) {
|
||||
if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) return null;
|
||||
ring.push([c[0], c[1]]);
|
||||
}
|
||||
// Drop the GeoJSON closing duplicate to match the runtime ring (not explicitly closed).
|
||||
const f = ring[0];
|
||||
const l = ring[ring.length - 1];
|
||||
if (ring.length > 3 && f[0] === l[0] && f[1] === l[1]) ring.pop();
|
||||
const anchor = fromPosition(p['gev:anchor']) || ringCentroid(ring);
|
||||
return {
|
||||
...base,
|
||||
anchor,
|
||||
to: null,
|
||||
ring,
|
||||
footprintKind: p['gev:footprintKind'] ?? null,
|
||||
buildingHeight: p['gev:buildingHeight'] ?? null,
|
||||
synthesized: Boolean(p['gev:synthesized']),
|
||||
};
|
||||
}
|
||||
|
||||
// Point geometry — pin / highlight / label, or a degenerate area.
|
||||
if (g.type !== 'Point') return null;
|
||||
const anchor = fromPosition(g.coordinates);
|
||||
if (!anchor) return null;
|
||||
const out = { ...base, anchor, to: null, ring: null };
|
||||
if (type === 'area') {
|
||||
out.footprintKind = p['gev:footprintKind'] ?? null;
|
||||
out.buildingHeight = null;
|
||||
out.synthesized = Boolean(p['gev:synthesized']);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of runtime annotations to a GeoJSON FeatureCollection.
|
||||
* Marks that can't form a valid geometry are dropped (not thrown).
|
||||
* @param {Array<object>} annotations
|
||||
* @returns {{type:'FeatureCollection', features: object[]}}
|
||||
*/
|
||||
export function annotationsToFeatureCollection(annotations) {
|
||||
const features = (Array.isArray(annotations) ? annotations : [])
|
||||
.map(annotationToFeature)
|
||||
.filter(Boolean);
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a GeoJSON FeatureCollection back to runtime annotation objects.
|
||||
* Malformed features are skipped (fail-closed per feature), never throwing.
|
||||
* @param {object} collection
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function featureCollectionToAnnotations(collection) {
|
||||
if (!collection || collection.type !== 'FeatureCollection' || !Array.isArray(collection.features)) return [];
|
||||
return collection.features.map(featureToAnnotation).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Pure round-trip + fail-closed tests for the annotation <-> GeoJSON interchange.
|
||||
// Run with: npm test (node --test). No framework, no Cesium, no browser.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
annotationToFeature,
|
||||
featureToAnnotation,
|
||||
annotationsToFeatureCollection,
|
||||
featureCollectionToAnnotations,
|
||||
} from './annotationGeoJson.js';
|
||||
|
||||
// A round-trip preserves the semantic fields (transient render state is intentionally not carried).
|
||||
const roundTrip = (anno) => featureToAnnotation(annotationToFeature(anno));
|
||||
|
||||
test('point (pin/highlight/label) round-trips, height preserved', () => {
|
||||
const pin = { type: 'pin', id: 'anno-1', label: 'ILM', color: 'primary', ttlMs: null,
|
||||
anchor: { lon: -77.9, lat: 34.27, height: 12 }, to: null, ring: null };
|
||||
const f = annotationToFeature(pin);
|
||||
assert.equal(f.geometry.type, 'Point');
|
||||
assert.deepEqual(f.geometry.coordinates, [-77.9, 34.27, 12]);
|
||||
assert.equal(f.properties['gev:type'], 'pin');
|
||||
assert.deepEqual(roundTrip(pin), pin);
|
||||
|
||||
const hl = { type: 'highlight', id: 'anno-2', label: 'spot', color: 'amber', ttlMs: 30000,
|
||||
anchor: { lon: 2.29, lat: 48.85 }, to: null, ring: null };
|
||||
assert.deepEqual(roundTrip(hl), hl); // no height -> 2D position
|
||||
});
|
||||
|
||||
test('area polygon round-trips: ring closed in GeoJSON, un-closed on import; centroid preserved', () => {
|
||||
const area = { type: 'area', id: 'anno-3', label: 'Marina', color: 'primary', ttlMs: null,
|
||||
anchor: { lon: -122.434, lat: 37.804 }, to: null,
|
||||
ring: [[-122.44, 37.80], [-122.43, 37.81], [-122.42, 37.80], [-122.43, 37.79]],
|
||||
footprintKind: 'area', buildingHeight: null, synthesized: false };
|
||||
const f = annotationToFeature(area);
|
||||
assert.equal(f.geometry.type, 'Polygon');
|
||||
const gjRing = f.geometry.coordinates[0];
|
||||
assert.equal(gjRing.length, 5); // 4 vertices + explicit closing point
|
||||
assert.deepEqual(gjRing[0], gjRing[gjRing.length - 1]); // closed
|
||||
assert.deepEqual(f.properties['gev:anchor'], [-122.434, 37.804]);
|
||||
assert.deepEqual(roundTrip(area), area); // ring back to 4, anchor from gev:anchor
|
||||
});
|
||||
|
||||
test('synthesized + building props are preserved', () => {
|
||||
const synth = { type: 'area', id: 'anno-4', label: 'around', color: 'primary', ttlMs: null,
|
||||
anchor: { lon: -97.74, lat: 30.27 }, to: null,
|
||||
ring: [[-97.75, 30.27], [-97.74, 30.28], [-97.73, 30.27]],
|
||||
footprintKind: 'area', buildingHeight: null, synthesized: true };
|
||||
const f = annotationToFeature(synth);
|
||||
assert.equal(f.properties['gev:synthesized'], true);
|
||||
assert.equal(roundTrip(synth).synthesized, true);
|
||||
|
||||
const bldg = { type: 'area', id: 'anno-5', label: 'Pentagon', color: 'primary', ttlMs: null,
|
||||
anchor: { lon: -77.056, lat: 38.871 }, to: null,
|
||||
ring: [[-77.057, 38.870], [-77.055, 38.872], [-77.054, 38.870]],
|
||||
footprintKind: 'building', buildingHeight: 24, synthesized: false };
|
||||
assert.equal(roundTrip(bldg).footprintKind, 'building');
|
||||
assert.equal(roundTrip(bldg).buildingHeight, 24);
|
||||
});
|
||||
|
||||
test('route path round-trips with mode/distance/duration', () => {
|
||||
const path = [{ lon: -122.4, lat: 37.7, height: 5 }, { lon: -122.39, lat: 37.71, height: 6 }, { lon: -122.38, lat: 37.72, height: 7 }];
|
||||
const route = { type: 'route', id: 'anno-6', label: '590 m · 8 min', color: 'primary', ttlMs: null,
|
||||
anchor: { lon: -122.4, lat: 37.7, height: 5 }, to: null, ring: null,
|
||||
path, mode: 'foot', distanceM: 590, durationS: 480, fallback: false };
|
||||
const f = annotationToFeature(route);
|
||||
assert.equal(f.geometry.type, 'LineString');
|
||||
assert.equal(f.geometry.coordinates.length, 3);
|
||||
assert.deepEqual(roundTrip(route), route);
|
||||
});
|
||||
|
||||
test('arrow round-trips (anchor -> to)', () => {
|
||||
const arrow = { type: 'arrow', id: 'anno-7', label: '2 km', color: 'primary', ttlMs: null,
|
||||
anchor: { lon: -122.43, lat: 37.80, height: 3 }, to: { lon: -122.40, lat: 37.81, height: 4 }, ring: null };
|
||||
const f = annotationToFeature(arrow);
|
||||
assert.equal(f.geometry.type, 'LineString');
|
||||
assert.equal(f.geometry.coordinates.length, 2);
|
||||
assert.deepEqual(roundTrip(arrow), arrow);
|
||||
});
|
||||
|
||||
test('degenerate area (no ring) -> Point, round-trips', () => {
|
||||
const ptArea = { type: 'area', id: 'anno-8', label: 'X', color: 'primary', ttlMs: null,
|
||||
anchor: { lon: 10, lat: 20 }, to: null, ring: null, footprintKind: null, buildingHeight: null, synthesized: false };
|
||||
const f = annotationToFeature(ptArea);
|
||||
assert.equal(f.geometry.type, 'Point');
|
||||
const back = roundTrip(ptArea);
|
||||
assert.equal(back.type, 'area');
|
||||
assert.equal(back.ring, null);
|
||||
assert.deepEqual(back.anchor, { lon: 10, lat: 20 });
|
||||
});
|
||||
|
||||
test('FeatureCollection round-trips a mixed set', () => {
|
||||
const annos = [
|
||||
{ type: 'pin', id: 'a', label: 'p', color: 'primary', ttlMs: null, anchor: { lon: 1, lat: 2 }, to: null, ring: null },
|
||||
{ type: 'area', id: 'b', label: 'q', color: 'amber', ttlMs: null, anchor: { lon: 3, lat: 4 }, to: null,
|
||||
ring: [[3, 4], [4, 5], [5, 4]], footprintKind: 'area', buildingHeight: null, synthesized: false },
|
||||
];
|
||||
const fc = annotationsToFeatureCollection(annos);
|
||||
assert.equal(fc.type, 'FeatureCollection');
|
||||
assert.equal(fc.features.length, 2);
|
||||
assert.deepEqual(featureCollectionToAnnotations(fc), annos);
|
||||
});
|
||||
|
||||
test('malformed input fails CLOSED (returns null / skips)', () => {
|
||||
assert.equal(featureToAnnotation(null), null);
|
||||
assert.equal(featureToAnnotation({}), null);
|
||||
assert.equal(featureToAnnotation({ type: 'NotAFeature' }), null);
|
||||
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: {} }), null); // no gev:type
|
||||
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: { 'gev:type': 'route' } }), null); // route needs LineString
|
||||
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1], [2, 2]] }, properties: { 'gev:type': 'arrow' } }), null); // arrow needs exactly 2
|
||||
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1]]] }, properties: { 'gev:type': 'area' } }), null); // <4 positions
|
||||
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: ['x', 0] }, properties: { 'gev:type': 'pin' } }), null); // non-finite
|
||||
// unknown type on the annotation side too
|
||||
assert.equal(annotationToFeature({ type: 'blob', anchor: { lon: 0, lat: 0 } }), null);
|
||||
assert.equal(annotationToFeature(null), null);
|
||||
// collection helpers never throw on junk
|
||||
assert.deepEqual(featureCollectionToAnnotations({ type: 'X' }), []);
|
||||
assert.deepEqual(annotationsToFeatureCollection('nope'), { type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,364 @@
|
||||
// Footprint-selection contract tests — pure fixtures, no network, no browser.
|
||||
//
|
||||
// Locks the monument-resolution regression:
|
||||
// a POINT-LIKE target ("Tejano Monument, Austin") must never adopt a nearby
|
||||
// polygon that merely shares locality/context words ("Austin", "History").
|
||||
// The fixtures replicate the REAL Overpass candidates captured over the Texas
|
||||
// Capitol on 2026-07-01, where "Thompson Austin" (a hotel 680 m away) outscored
|
||||
// everything because `nameOverlap * 1000` paid +1000 for the single word
|
||||
// "Austin" — the monument itself is an OSM node and never even a candidate.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveAnnotationTarget,
|
||||
selectFootprint,
|
||||
refineScope,
|
||||
isGroundsLikeAsk,
|
||||
} from './annotationResolver.js';
|
||||
|
||||
// Build a square way of ~`areaM2` centred `dLatM`/`dLonM` metres from an anchor,
|
||||
// in Overpass `out geom` element shape ({ tags, geometry: [{lat,lon}...] }).
|
||||
function squareWay(anchor, dLatM, dLonM, areaM2, tags) {
|
||||
const mLat = 111320;
|
||||
const mLon = mLat * Math.cos((anchor.lat * Math.PI) / 180);
|
||||
const cLat = anchor.lat + dLatM / mLat;
|
||||
const cLon = anchor.lon + dLonM / mLon;
|
||||
const half = Math.sqrt(areaM2) / 2;
|
||||
const dy = half / mLat;
|
||||
const dx = half / mLon;
|
||||
const ring = [
|
||||
{ lat: cLat - dy, lon: cLon - dx },
|
||||
{ lat: cLat - dy, lon: cLon + dx },
|
||||
{ lat: cLat + dy, lon: cLon + dx },
|
||||
{ lat: cLat + dy, lon: cLon - dx },
|
||||
{ lat: cLat - dy, lon: cLon - dx },
|
||||
];
|
||||
return { type: 'way', tags, geometry: ring };
|
||||
}
|
||||
|
||||
// The real Places anchor for "Tejano Monument, Austin" (on the Capitol grounds).
|
||||
const ANCHOR = { lat: 30.27297, lon: -97.74029 };
|
||||
|
||||
// Real captured wrong-winners: named downtown features that share ONLY "Austin".
|
||||
const wrongWinners = () => [
|
||||
squareWay(ANCHOR, -660, -90, 3093, { building: 'yes', tourism: 'hotel', name: 'Thompson Austin' }),
|
||||
squareWay(ANCHOR, -780, 120, 3772, { building: 'yes', name: 'Austin Police HQ' }),
|
||||
squareWay(ANCHOR, -380, -60, 4499, { leisure: 'park', name: 'Black Austin Matters' }),
|
||||
squareWay(ANCHOR, -240, -700, 1500, { building: 'yes', amenity: 'library', name: 'Austin Public Library - Austin History Center' }),
|
||||
// The real enclosing grounds polygon — unnamed overlap with the monument query.
|
||||
squareWay(ANCHOR, 0, 0, 100_000, { leisure: 'park', name: 'Capitol Square' }),
|
||||
];
|
||||
|
||||
test('point mode: locality-word polygons never stand in for a monument (the Tejano bug)', () => {
|
||||
const fp = selectFootprint(wrongWinners(), ANCHOR.lat, ANCHOR.lon, 'Tejano Monument, Austin', 'point');
|
||||
assert.equal(fp, null); // keep the honest point anchor
|
||||
});
|
||||
|
||||
test('point mode: an (almost) exactly-named, monument-scale polygon still outlines', () => {
|
||||
const els = wrongWinners();
|
||||
els.push(squareWay(ANCHOR, 5, 5, 300, { tourism: 'artwork', name: 'Tejano Monument' }));
|
||||
const fp = selectFootprint(els, ANCHOR.lat, ANCHOR.lon, 'Tejano Monument, Austin', 'point');
|
||||
assert.ok(fp, 'expected the true monument way to be accepted');
|
||||
assert.equal(fp.kind, 'area');
|
||||
// Ring is centred on the true feature (a few metres from the anchor), not downtown.
|
||||
const lat0 = fp.ring[0][1];
|
||||
assert.ok(Math.abs(lat0 - ANCHOR.lat) < 0.001, `ring landed at ${lat0}, expected ~${ANCHOR.lat}`);
|
||||
});
|
||||
|
||||
test('point mode: an exactly-named but park-sized polygon is rejected (size cap)', () => {
|
||||
// "Pioneer Monument, Golden Gate Park" — the park matches 3/3 of its own name
|
||||
// and contains the anchor, but a 4.1 km² park must not render as a monument.
|
||||
const park = squareWay(ANCHOR, 0, 0, 4_100_000, { leisure: 'park', name: 'Golden Gate Park' });
|
||||
const fp = selectFootprint([park], ANCHOR.lat, ANCHOR.lon, 'Pioneer Monument, Golden Gate Park', 'point');
|
||||
assert.equal(fp, null);
|
||||
});
|
||||
|
||||
test('loose mode: unchanged — word-overlap scoring still picks the named candidate', () => {
|
||||
// Pins that the fix is SCOPED to point-like targets: generic loose lookups keep
|
||||
// the existing scorer (changing it globally would need its own field evidence).
|
||||
const fp = selectFootprint(wrongWinners(), ANCHOR.lat, ANCHOR.lon, 'Tejano Monument, Austin', 'loose');
|
||||
assert.ok(fp, 'loose mode still resolves a footprint');
|
||||
});
|
||||
|
||||
test('loose mode: a named compound still beats an unnamed containing building (Presidio case)', () => {
|
||||
const els = [
|
||||
squareWay(ANCHOR, 0, 0, 900, { building: 'yes' }), // unnamed building under the anchor
|
||||
squareWay(ANCHOR, 400, 400, 6_000_000, { landuse: 'military', name: 'Presidio of San Francisco' }),
|
||||
];
|
||||
const fp = selectFootprint(els, ANCHOR.lat, ANCHOR.lon, 'Presidio of San Francisco', 'loose');
|
||||
assert.ok(fp);
|
||||
assert.equal(fp.kind, 'area');
|
||||
});
|
||||
|
||||
test('strict mode: unchanged — named district-sized areas only', () => {
|
||||
const els = [
|
||||
squareWay(ANCHOR, 0, 0, 900, { building: 'yes', name: 'Mission Lofts' }), // building → rejected
|
||||
squareWay(ANCHOR, 0, 0, 12_000, { landuse: 'retail', name: 'Mission Market' }), // tiny parcel → rejected
|
||||
squareWay(ANCHOR, 200, 200, 500_000, { landuse: 'residential', name: 'Mission District' }),
|
||||
];
|
||||
const fp = selectFootprint(els, ANCHOR.lat, ANCHOR.lon, 'Mission District', 'strict');
|
||||
assert.ok(fp);
|
||||
assert.equal(fp.kind, 'area');
|
||||
assert.ok(Math.abs(fp.ring[0][1] - (ANCHOR.lat + 200 / 111320)) < 0.01);
|
||||
});
|
||||
|
||||
test('loose mode: a named water body beats a shore feature named after it (field test 9)', () => {
|
||||
// "Lady Bird Lake" — the anchor sits ON the water. Before natural=water joined the
|
||||
// sweep, the lake was never a candidate and a shoreline park NAMED AFTER it won on
|
||||
// word overlap, drawing a squiggle on the bank instead of the lake.
|
||||
const ANCHOR_ON_WATER = { lat: 30.2565, lon: -97.7365 };
|
||||
const els = [
|
||||
// The real lake: large named water polygon containing the anchor.
|
||||
squareWay(ANCHOR_ON_WATER, 0, 0, 3_500_000, { natural: 'water', water: 'reservoir', name: 'Lady Bird Lake' }),
|
||||
// Shore park named after the lake (partial name coverage, does not contain anchor).
|
||||
squareWay(ANCHOR_ON_WATER, 450, -300, 90_000, { leisure: 'park', name: 'Auditorium Shores at Lady Bird Lake Metropolitan Park' }),
|
||||
];
|
||||
const fp = selectFootprint(els, ANCHOR_ON_WATER.lat, ANCHOR_ON_WATER.lon, 'Lady Bird Lake, Austin', 'loose');
|
||||
assert.ok(fp);
|
||||
assert.equal(fp.kind, 'area');
|
||||
// Ring centred on the lake fixture, not offset onto the shore park.
|
||||
const lat0 = fp.ring[0][1];
|
||||
assert.ok(Math.abs(lat0 - ANCHOR_ON_WATER.lat) < 0.02, 'ring at ' + lat0);
|
||||
assert.ok(Math.abs(lat0 - (ANCHOR_ON_WATER.lat + 450 / 111320)) > 0.001, 'must not be the shore park');
|
||||
|
||||
// Inverse: a LAND ask near the water is not stolen by the lake polygon.
|
||||
const park = selectFootprint(els, ANCHOR_ON_WATER.lat + 0.004, ANCHOR_ON_WATER.lon - 0.003, 'Auditorium Shores', 'loose');
|
||||
assert.ok(park);
|
||||
assert.ok(Math.abs(park.ring[0][1] - (ANCHOR_ON_WATER.lat + 450 / 111320)) < 0.01, 'land ask keeps the park');
|
||||
});
|
||||
|
||||
test('isGroundsLikeAsk: label wording and entityKind both count (field test 8)', () => {
|
||||
// The model's real call shape: grounds word only in the LABEL, compound entityKind.
|
||||
assert.equal(isGroundsLikeAsk('Texas State Capitol, Austin', 'Capitol grounds', 'compound'), true);
|
||||
// Label alone is enough when no entityKind is given.
|
||||
assert.equal(isGroundsLikeAsk('Texas State Capitol, Austin', 'Capitol grounds', null), true);
|
||||
// Target wording still works as before.
|
||||
assert.equal(isGroundsLikeAsk('Texas State Capitol grounds, Austin', null, null), true);
|
||||
// An explicit non-compound entityKind vetoes grounds wording (trust the model's fact).
|
||||
assert.equal(isGroundsLikeAsk('Capitol complex', 'the complex', 'building'), false);
|
||||
// A plain building ask is not grounds-like.
|
||||
assert.equal(isGroundsLikeAsk('Texas State Capitol, Austin', 'Texas State Capitol', null), false);
|
||||
});
|
||||
|
||||
test('refineScope: entityKind refines only an unresolved (auto) scope', () => {
|
||||
assert.equal(refineScope('auto', 'building'), 'building');
|
||||
assert.equal(refineScope('auto', 'compound'), 'compound');
|
||||
assert.equal(refineScope('auto', 'district'), 'neighborhood');
|
||||
assert.equal(refineScope('auto', 'street'), 'street');
|
||||
assert.equal(refineScope('auto', 'point_feature'), 'auto'); // point-first handled separately
|
||||
assert.equal(refineScope('auto', undefined), 'auto');
|
||||
// Real geocode types (data) always win over the model's claim.
|
||||
assert.equal(refineScope('city', 'building'), 'city');
|
||||
assert.equal(refineScope('neighborhood', 'street'), 'neighborhood');
|
||||
});
|
||||
|
||||
function closeViewportViewer() {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: 30.2672 * Math.PI / 180,
|
||||
longitude: -97.7431 * Math.PI / 180,
|
||||
height: 1000,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function geocodePayload({ lat, lon, types, label }) {
|
||||
return {
|
||||
status: 'OK',
|
||||
results: [{
|
||||
formatted_address: label,
|
||||
types,
|
||||
address_components: [{ long_name: label.split(',')[0], types }],
|
||||
geometry: { location: { lat, lng: lon } },
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function installGoogleMocks(t, handler) {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.window = {
|
||||
__GOOGLE_MAPS_API_KEY__: 'unit-test-key',
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
globalThis.fetch = handler;
|
||||
t.after(() => {
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
}
|
||||
|
||||
test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a far state-typed geocode', async (t) => {
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
label: 'Texas, USA',
|
||||
}) };
|
||||
}
|
||||
assert.match(String(url), /^\/api\/google\/text-search\?/);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ places: [{
|
||||
latitude: 30.2747,
|
||||
longitude: -97.7404,
|
||||
name: 'Texas Capitol',
|
||||
types: ['premise'],
|
||||
}] }),
|
||||
};
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
target: 'the Texas Capitol',
|
||||
});
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.source, 'places');
|
||||
assert.deepEqual([resolved.lat, resolved.lon], [30.2747, -97.7404]);
|
||||
assert.equal(calls.length, 2, 'admin result types must not suppress near-view recovery');
|
||||
});
|
||||
|
||||
test('ask-side admin bypass: explicit "state of Texas" skips recovery and proximity gating', async (t) => {
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
label: 'Texas, USA',
|
||||
}) };
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
target: 'state of Texas',
|
||||
});
|
||||
|
||||
assert.ok(resolved, 'the explicit state ask keeps its legitimate far centroid');
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
assert.deepEqual([resolved.lat, resolved.lon], [31, -99]);
|
||||
assert.equal(calls.length, 1, 'explicit state scope bypasses near-view recovery');
|
||||
});
|
||||
|
||||
for (const fixture of [
|
||||
{
|
||||
target: 'Empire State',
|
||||
lat: 40.7484,
|
||||
lon: -73.9857,
|
||||
types: ['premise', 'tourist_attraction'],
|
||||
},
|
||||
{
|
||||
target: 'Ohio State',
|
||||
lat: 40.0067,
|
||||
lon: -83.0305,
|
||||
types: ['university'],
|
||||
},
|
||||
]) {
|
||||
test(`ask-side admin bypass: trailing name "${fixture.target}" remains guarded`, async (t) => {
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: fixture.lat,
|
||||
lon: fixture.lon,
|
||||
types: fixture.types,
|
||||
label: `${fixture.target}, USA`,
|
||||
}) };
|
||||
}
|
||||
assert.match(String(url), /^\/api\/google\/text-search\?/);
|
||||
return { ok: true, json: async () => ({ places: [] }) };
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
target: fixture.target,
|
||||
});
|
||||
|
||||
assert.equal(resolved, null, 'a recovery miss continues through the proximity gate');
|
||||
assert.equal(calls.length, 2, 'a proper name ending in State must try near-view recovery');
|
||||
});
|
||||
}
|
||||
|
||||
test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path', async (t) => {
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
label: 'Texas, USA',
|
||||
}) };
|
||||
}
|
||||
assert.match(String(url), /^\/api\/google\/text-search\?/);
|
||||
return { ok: true, json: async () => ({ places: [] }) };
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
target: 'Texas',
|
||||
});
|
||||
|
||||
assert.equal(resolved, null, 'a recovery miss continues through the proximity gate');
|
||||
assert.equal(calls.length, 2, 'bare state names are not an explicit admin-scoped ask');
|
||||
});
|
||||
|
||||
test('ask-side admin bypass: admin level 2/3 result types never grant a township bypass', async (t) => {
|
||||
const fixtures = new Map([
|
||||
['FB-3 township level 2 fixture', ['administrative_area_level_2', 'locality', 'political']],
|
||||
['FB-3 township level 3 fixture', ['administrative_area_level_3', 'locality', 'political']],
|
||||
]);
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
const query = new URL(String(url)).searchParams.get('address');
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 39.7817,
|
||||
lon: -89.6501,
|
||||
types: fixtures.get(query),
|
||||
label: `${query}, Illinois`,
|
||||
}) };
|
||||
}
|
||||
assert.match(String(url), /^\/api\/google\/text-search\?/);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ places: [{
|
||||
latitude: 30.2680,
|
||||
longitude: -97.7425,
|
||||
name: 'Local township fixture',
|
||||
types: ['locality'],
|
||||
}] }),
|
||||
};
|
||||
});
|
||||
|
||||
for (const target of fixtures.keys()) {
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
target,
|
||||
});
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.source, 'places');
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
calls.filter((url) => url.startsWith('/api/google/text-search')).length,
|
||||
2,
|
||||
'both township-level admin result types stay guarded',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
|
||||
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
|
||||
/**
|
||||
* Hybrid annotation renderer (Direction C — the recommended production blend).
|
||||
*
|
||||
* Routes each mark to the renderer that does it best:
|
||||
* - `area` footprints → WORLD-space, so the outline/fill drapes onto and
|
||||
* conforms to the real 3D building/ground geometry (screen-space can only
|
||||
* draw a flat polygon that ignores relief and building tops).
|
||||
* - everything else (highlight / pin / arrow / label) → SCREEN-space SVG, for
|
||||
* the hand-drawn "whiteboard" aesthetic: pulsing reticles, bowed arrows,
|
||||
* and glassy callout cards with leader lines.
|
||||
*
|
||||
* The area's text label is also drawn as a SCREEN callout (the world drape is
|
||||
* label-less) so every caption shares one consistent style.
|
||||
*
|
||||
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
|
||||
* one route change that spans both sub-renderers.
|
||||
*/
|
||||
export function createHybridAnnotationRenderer(viewer) {
|
||||
const world = createWorldAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(viewer);
|
||||
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
|
||||
const routed = new Map();
|
||||
|
||||
function add(anno) {
|
||||
const entry = {};
|
||||
// Route the entry FIRST, then fill it in. A sub-renderer throw (WebGL loss
|
||||
// mid-add) otherwise stranded whatever the earlier sub-renderer had already
|
||||
// created: `routed` never learned the id, so remove() was a no-op and the
|
||||
// engine's rollback could not reach the orphan — a re-annotate of the same
|
||||
// geometry then stacked a second mark over it. Registered up front, the
|
||||
// partial entry is addressable and remove() cleans exactly what exists.
|
||||
routed.set(anno.id, entry);
|
||||
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
|
||||
// Drape the footprint in world space, but without its own label...
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
world.add(entry.worldProxy);
|
||||
// ...and place the caption as a screen-space callout at the centroid.
|
||||
if (anno.label) {
|
||||
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||
screen.add(entry.screenProxy);
|
||||
}
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
// Drape the path on the tiles (world), caption it with a screen callout
|
||||
// at the path midpoint.
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
world.add(entry.worldProxy);
|
||||
if (anno.label) {
|
||||
const mid = anno.path[Math.floor(anno.path.length / 2)];
|
||||
entry.screenProxy = liveProxy(anno, {
|
||||
type: 'label',
|
||||
path: null,
|
||||
anchor: { lon: mid.lon, lat: mid.lat, height: mid.height },
|
||||
});
|
||||
screen.add(entry.screenProxy);
|
||||
}
|
||||
} else {
|
||||
entry.screenProxy = anno;
|
||||
screen.add(anno);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade a pending area without replacing its existing screen-space group.
|
||||
* World geometry is new, but the reticle's SVG group is converted in place to
|
||||
* the centroid callout so there is no overlapping fade-out/fade-in pair.
|
||||
*/
|
||||
function update(anno) {
|
||||
const entry = routed.get(anno.id);
|
||||
if (!entry) {
|
||||
add(anno);
|
||||
return;
|
||||
}
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) return;
|
||||
|
||||
if (!entry.worldProxy) {
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
world.add(entry.worldProxy);
|
||||
}
|
||||
|
||||
const screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||
if (entry.screenProxy) {
|
||||
screen.update(screenProxy, { empty: !anno.label });
|
||||
} else if (anno.label) {
|
||||
screen.add(screenProxy);
|
||||
}
|
||||
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release both routes. Tolerates PARTIAL state (only one sub-renderer got
|
||||
* its content) and an absent id, so it doubles as the rollback path for a
|
||||
* failed add.
|
||||
*
|
||||
* Both releases are ALWAYS attempted, each clears its own proxy as it
|
||||
* succeeds, and the routing entry is dropped only once nothing is left to
|
||||
* release. A release that throws therefore leaves the id still addressable,
|
||||
* so a retry (or a later clear) can finish the job instead of the orphan
|
||||
* becoming permanently unreachable. The failure is rethrown after both
|
||||
* routes have had their turn.
|
||||
*/
|
||||
function remove(anno) {
|
||||
const entry = routed.get(anno.id);
|
||||
if (!entry) return;
|
||||
let failure = null;
|
||||
if (entry.worldProxy) {
|
||||
try {
|
||||
world.remove(entry.worldProxy);
|
||||
entry.worldProxy = null;
|
||||
} catch (error) {
|
||||
failure = error; // keep the proxy: it still owns live geometry
|
||||
}
|
||||
}
|
||||
if (entry.screenProxy) {
|
||||
try {
|
||||
screen.remove(entry.screenProxy);
|
||||
entry.screenProxy = null;
|
||||
} catch (error) {
|
||||
failure = failure || error;
|
||||
}
|
||||
}
|
||||
if (!entry.worldProxy && !entry.screenProxy) routed.delete(anno.id);
|
||||
if (failure) throw failure;
|
||||
}
|
||||
|
||||
function sync(annotations) {
|
||||
world.sync(annotations);
|
||||
screen.sync(annotations);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
world.destroy();
|
||||
screen.destroy();
|
||||
routed.clear();
|
||||
}
|
||||
|
||||
return { add, update, remove, sync, destroy };
|
||||
}
|
||||
|
||||
/**
|
||||
* A proxy that inherits from `anno` via the prototype chain (so live fields the
|
||||
* engine mutates each tick — `alpha`, `bornAt`, `expiring` — are read through),
|
||||
* with a few own properties overridden. Sub-renderers store their own state
|
||||
* (`_entities`, SVG refs) as own properties on the proxy, isolated per route.
|
||||
*/
|
||||
function liveProxy(anno, overrides) {
|
||||
return Object.assign(Object.create(anno), overrides);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
this.names = new Set();
|
||||
}
|
||||
|
||||
reset(value) {
|
||||
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
for (const name of names) this.names.add(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
for (const name of names) this.names.delete(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.names.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this.classList = new FakeClassList(this);
|
||||
this.textContent = '';
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.classList.reset(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.setAttribute('class', value);
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.getAttribute('class') || '';
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
this.setAttribute('id', value);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.getAttribute('id') || '';
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) this.children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parentNode?.removeChild(this);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (className && child.classList.contains(className)) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
|
||||
getBBox() {
|
||||
return { x: 0, y: 0, width: 40, height: 16 };
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeDocument() {
|
||||
const head = new FakeElement('head');
|
||||
const body = new FakeElement('body');
|
||||
const roots = [head, body];
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
||||
getElementById(id) {
|
||||
let found = null;
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute('id') === id) found = node;
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findAnnotationGroup(document) {
|
||||
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
||||
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
||||
return {
|
||||
svg,
|
||||
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
||||
};
|
||||
}
|
||||
|
||||
function cameraLookingAt(lon, lat) {
|
||||
const target = Cesium.Cartesian3.fromDegrees(lon, lat);
|
||||
const radial = Cesium.Cartesian3.normalize(target, new Cesium.Cartesian3());
|
||||
const position = Cesium.Cartesian3.multiplyByScalar(
|
||||
radial,
|
||||
Cesium.Cartesian3.magnitude(target) + 1_000_000,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const direction = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.subtract(target, position, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const right = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Z, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const up = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const frustum = new Cesium.PerspectiveFrustum({
|
||||
fov: Cesium.Math.toRadians(60),
|
||||
aspectRatio: 1280 / 720,
|
||||
near: 1,
|
||||
far: 20_000_000,
|
||||
});
|
||||
return {
|
||||
positionWC: position,
|
||||
directionWC: direction,
|
||||
rightWC: right,
|
||||
upWC: up,
|
||||
viewMatrix: Cesium.Matrix4.computeView(
|
||||
position,
|
||||
direction,
|
||||
up,
|
||||
right,
|
||||
new Cesium.Matrix4(),
|
||||
),
|
||||
frustum,
|
||||
positionCartographic: { height: 1_000_000 },
|
||||
};
|
||||
}
|
||||
|
||||
/** Install the browser globals both renderers touch, restored after the test. */
|
||||
function installBrowserGlobals(t) {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalBrowserConstructors = new Map([
|
||||
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
|
||||
['HTMLImageElement', globalThis.HTMLImageElement],
|
||||
['ImageBitmap', globalThis.ImageBitmap],
|
||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||
]);
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
for (const [name, original] of originalBrowserConstructors) {
|
||||
if (original === undefined) delete globalThis[name];
|
||||
else globalThis[name] = original;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** A viewer whose scene/camera is just enough for both sub-renderers. */
|
||||
function fakeViewer(lon, lat) {
|
||||
const camera = cameraLookingAt(lon, lat);
|
||||
const dataSources = [];
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight: (world) => world,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
return {
|
||||
dataSources,
|
||||
viewer: {
|
||||
scene,
|
||||
camera,
|
||||
trackedEntity: null,
|
||||
dataSources: {
|
||||
add(dataSource) {
|
||||
dataSources.push(dataSource);
|
||||
return dataSource;
|
||||
},
|
||||
remove(dataSource) {
|
||||
const index = dataSources.indexOf(dataSource);
|
||||
if (index >= 0) dataSources.splice(index, 1);
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function annotationGroups(svg) {
|
||||
return svg.children.filter((child) => child.classList.contains('gev-anno'));
|
||||
}
|
||||
|
||||
function naiveRingCentroid(ring) {
|
||||
const totals = ring.reduce(
|
||||
(sum, [lon, lat]) => ({ lon: sum.lon + lon, lat: sum.lat + lat }),
|
||||
{ lon: 0, lat: 0 },
|
||||
);
|
||||
return { lon: totals.lon / ring.length, lat: totals.lat / ring.length, height: 0 };
|
||||
}
|
||||
|
||||
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalBrowserConstructors = new Map([
|
||||
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
|
||||
['HTMLImageElement', globalThis.HTMLImageElement],
|
||||
['ImageBitmap', globalThis.ImageBitmap],
|
||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||
]);
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
for (const [name, original] of originalBrowserConstructors) {
|
||||
if (original === undefined) delete globalThis[name];
|
||||
else globalThis[name] = original;
|
||||
}
|
||||
});
|
||||
|
||||
const camera = cameraLookingAt(-99, 31);
|
||||
const dataSources = [];
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight: (world) => world,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const viewer = {
|
||||
scene,
|
||||
camera,
|
||||
trackedEntity: null,
|
||||
dataSources: {
|
||||
add(dataSource) {
|
||||
dataSources.push(dataSource);
|
||||
return dataSource;
|
||||
},
|
||||
remove(dataSource) {
|
||||
const index = dataSources.indexOf(dataSource);
|
||||
if (index >= 0) dataSources.splice(index, 1);
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const anno = {
|
||||
id: 'anno-hybrid-fb3',
|
||||
type: 'area',
|
||||
color: 'primary',
|
||||
label: 'Texas',
|
||||
alpha: 1,
|
||||
anchor: { lon: -99, lat: 31, height: 0 },
|
||||
ring: null,
|
||||
footprintKind: null,
|
||||
synthesized: false,
|
||||
};
|
||||
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
const originalCallout = before.group.querySelector('.gev-anno-callout');
|
||||
const originalDot = before.group.querySelector('.gev-anno-dot');
|
||||
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
|
||||
assert.equal(dataSources.length, 1);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
|
||||
const ring = [
|
||||
[-106, 25],
|
||||
[-93, 25],
|
||||
[-93, 36],
|
||||
[-106, 36],
|
||||
[-106, 25],
|
||||
];
|
||||
const centroid = naiveRingCentroid(ring);
|
||||
anno.ring = ring;
|
||||
anno.anchor = centroid;
|
||||
anno.footprintKind = 'area';
|
||||
renderer.update(anno);
|
||||
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
Cesium.Cartesian3.fromDegrees(centroid.lon, centroid.lat, centroid.height),
|
||||
);
|
||||
assert.equal(after.group, before.group, 'the real hybrid keeps the existing SVG group');
|
||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
||||
assert.equal(after.group.querySelectorAll('.gev-anno-ring').length, 0, 'screen reticle rings are removed');
|
||||
assert.equal(after.group.querySelector('.gev-anno-callout'), originalCallout, 'the callout node is retained');
|
||||
assert.equal(after.group.querySelector('.gev-anno-dot'), originalDot, 'the anchor dot is retained');
|
||||
assert.equal(originalDot.getAttribute('cx'), expectedWindow.x.toFixed(1));
|
||||
assert.equal(originalDot.getAttribute('cy'), expectedWindow.y.toFixed(1));
|
||||
assert.deepEqual(centroid, { lon: -100.8, lat: 29.4, height: 0 }, 'closed-ring vertex mean stays pinned');
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'world area adds one fill and one outline');
|
||||
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
|
||||
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polyline).length, 1);
|
||||
assert.equal(Object.hasOwn(anno, '_entities'), false, 'world entity state stays on the inherited proxy');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
// ── Partial-add rollback (second review) ─────────────────────────────────────
|
||||
//
|
||||
// The hybrid builds a mark across TWO sub-renderers. It used to record the
|
||||
// route only after both had run, so a throw in the second one left the first
|
||||
// one's content live with nothing pointing at it: remove() was a no-op and the
|
||||
// engine's rollback (which knows only the id) could not reach it. The next
|
||||
// annotate of the same geometry then stacked a fresh mark over that orphan.
|
||||
|
||||
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-partial-add',
|
||||
type: 'area',
|
||||
color: 'primary',
|
||||
label: 'Texas',
|
||||
alpha: 1,
|
||||
anchor: naiveRingCentroid(ring),
|
||||
ring,
|
||||
footprintKind: 'area',
|
||||
synthesized: false,
|
||||
};
|
||||
|
||||
// The world drape lands first; the screen caption then fails the way a lost
|
||||
// context does, AFTER the world geometry is already on the board.
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
const appendChild = svg.appendChild.bind(svg);
|
||||
let failNextInsert = true;
|
||||
svg.appendChild = (child) => {
|
||||
if (failNextInsert) {
|
||||
failNextInsert = false;
|
||||
throw new Error('screen insert failed');
|
||||
}
|
||||
return appendChild(child);
|
||||
};
|
||||
|
||||
assert.throws(() => renderer.add(anno), /screen insert failed/);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
|
||||
assert.equal(annotationGroups(svg).length, 0, 'the half-built screen group detached itself');
|
||||
|
||||
// The engine's rollback path — it must reach the partial state by id.
|
||||
renderer.remove(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'partial world state must be released');
|
||||
|
||||
// …and the id is free again: a retry draws ONE mark, not a second one over
|
||||
// an orphan nothing owns.
|
||||
renderer.add(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'and exactly one screen caption');
|
||||
renderer.remove(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-release-throw',
|
||||
type: 'area',
|
||||
color: 'primary',
|
||||
label: 'Texas',
|
||||
alpha: 1,
|
||||
anchor: naiveRingCentroid(ring),
|
||||
ring,
|
||||
footprintKind: 'area',
|
||||
synthesized: false,
|
||||
};
|
||||
|
||||
renderer.add(anno);
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(dataSources[0].entities.values.length, 2);
|
||||
assert.equal(annotationGroups(svg).length, 1);
|
||||
|
||||
// The world route releases fine, then the screen release fails part-way
|
||||
// (its fade-out timer throws) — so its SVG group is still in the document.
|
||||
const workingSetTimeout = globalThis.window.setTimeout;
|
||||
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
|
||||
assert.throws(() => renderer.remove(anno), /teardown scheduling failed/);
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'the world route did release');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'the screen route did NOT — that is the orphan');
|
||||
|
||||
// Dropping the routing entry here would make that orphan permanently
|
||||
// unreachable. It must still be addressable, so a retry finishes the job.
|
||||
globalThis.window.setTimeout = workingSetTimeout;
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0, 'the retry cleanup must reach the orphan');
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
|
||||
|
||||
// Fully released now — a third call is inert.
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-partial-world-add',
|
||||
type: 'area',
|
||||
color: 'primary',
|
||||
label: 'Texas',
|
||||
alpha: 1,
|
||||
anchor: naiveRingCentroid(ring),
|
||||
ring,
|
||||
footprintKind: 'area',
|
||||
synthesized: false,
|
||||
};
|
||||
|
||||
// The draped fill lands; the outline throws (bad geometry / lost context).
|
||||
const entities = dataSources[0].entities;
|
||||
const realAdd = entities.add.bind(entities);
|
||||
let addsLeft = 1;
|
||||
entities.add = (options) => {
|
||||
if (addsLeft <= 0) throw new Error('entity add failed');
|
||||
addsLeft -= 1;
|
||||
return realAdd(options);
|
||||
};
|
||||
|
||||
assert.throws(() => renderer.add(anno), /entity add failed/);
|
||||
assert.equal(entities.values.length, 1, 'one entity landed before the failure');
|
||||
|
||||
entities.add = realAdd;
|
||||
renderer.remove(anno);
|
||||
assert.equal(entities.values.length, 0,
|
||||
'the rollback must see the entities that landed before the throw');
|
||||
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
renderer.add(anno);
|
||||
assert.equal(entities.values.length, 2, 'the retry draws a complete mark');
|
||||
assert.equal(annotationGroups(svg).length, 1);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createAnnotationEngine } from './annotationEngine.js';
|
||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
|
||||
/**
|
||||
* Initialize the map-annotation engine and expose it for the voice agent and
|
||||
* for manual/dev use via `window.__gevAnnotations`.
|
||||
*
|
||||
* This module is the single swap point between annotation rendering strategies.
|
||||
* The HYBRID renderer uses world-space draping for
|
||||
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
||||
* and voice tool wiring are shared across rendering strategies.
|
||||
*/
|
||||
export function initAnnotations({ viewer, tileset = null }) {
|
||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||
if (tileset) {
|
||||
try { tileset.enableCollision = true; } catch { /* older tileset */ }
|
||||
}
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const engine = createAnnotationEngine({ viewer, renderer });
|
||||
window.__gevAnnotations = engine;
|
||||
return engine;
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { getOverlayPaintRect } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
getActiveTrackedReadoutId,
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
} from '../data/trackedReadout.js';
|
||||
|
||||
/**
|
||||
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
|
||||
*
|
||||
* Same world-anchored data model as the world-space renderer, but drawn as an
|
||||
* SVG overlay that is RE-PROJECTED every frame from the world coordinates via
|
||||
* `SceneTransforms.worldToWindowCoordinates`. That gives the best of both:
|
||||
*
|
||||
* - World-anchored, so marks PERSIST and track as the camera moves/orbits
|
||||
* (unlike a frame-bound pixel overlay that has to clear on camera motion).
|
||||
* - SVG-rendered, so we get the hand-drawn explainer look — sketchy strokes,
|
||||
* pulsing rings, draw-on outlines, arrows, and glassy callout cards with
|
||||
* leader lines — that native Cesium primitives can't express as nicely.
|
||||
*
|
||||
* Marks behind the globe horizon are culled with an EllipsoidalOccluder, and
|
||||
* anything that projects off-screen / behind the camera is hidden.
|
||||
*
|
||||
* Screen-renderer contract (update is used by the hybrid outline-upgrade path):
|
||||
* add(anno) / update(anno) / remove(anno) / sync(map) / destroy()
|
||||
*/
|
||||
|
||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
const PALETTE = {
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
};
|
||||
|
||||
// Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling
|
||||
// back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no
|
||||
// longer collapse into an overlapping blob when their anchors project close together.
|
||||
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
|
||||
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
|
||||
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
|
||||
const RING_OUTER_R = 34;
|
||||
const RING_INNER_R = 18;
|
||||
const DOT_R = 5;
|
||||
const LABEL_DOT_R = 4;
|
||||
function markScale(h) {
|
||||
if (!(h > MARK_SCALE_NEAR_H)) return 1;
|
||||
if (h >= MARK_SCALE_FAR_H) return MARK_SCALE_MIN;
|
||||
const t = (h - MARK_SCALE_NEAR_H) / (MARK_SCALE_FAR_H - MARK_SCALE_NEAR_H);
|
||||
return 1 - t * (1 - MARK_SCALE_MIN);
|
||||
}
|
||||
|
||||
export function createScreenAnnotationRenderer(viewer, {
|
||||
overlayPaintRect = getOverlayPaintRect,
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
} = {}) {
|
||||
injectStyles();
|
||||
const { layer, svg, defs } = buildOverlay();
|
||||
document.body.appendChild(layer);
|
||||
|
||||
const scene = viewer.scene;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC);
|
||||
const records = new Map(); // anno.id -> { anno, group, parts }
|
||||
const scratch = new Cesium.Cartesian2();
|
||||
const scratchDir = new Cesium.Cartesian3();
|
||||
// Per-point ground height cache. Marks must sit on the real surface, not at
|
||||
// sea level — otherwise in elevated cities (Austin ~150 m) they project
|
||||
// underground and parallax sinks them at oblique angles. Sampled once the
|
||||
// tiles under the point load, then settled (stable, no per-frame jitter).
|
||||
const heightCache = new Map();
|
||||
const HEIGHT_CACHE_SOFT = 600; // target size; over this we evict keys not used this frame
|
||||
const HEIGHT_CACHE_HARD = 8000; // absolute ceiling (one big ring + others) — never exceeded
|
||||
let projGen = 0; // bumped each projection frame; entries used this frame are "hot"
|
||||
function trimHeightCache() {
|
||||
if (heightCache.size <= HEIGHT_CACHE_SOFT) return;
|
||||
// Evict COLD keys first (not touched this frame), so a single large area/route
|
||||
// — the screen renderer can project a ring up to ~4000 points — cannot churn its
|
||||
// OWN active heights out of cache and force a clampToHeight every frame.
|
||||
for (const [k, v] of heightCache) {
|
||||
if (heightCache.size <= HEIGHT_CACHE_SOFT) break;
|
||||
if (v.gen !== projGen) heightCache.delete(k);
|
||||
}
|
||||
// Backstop if a single frame legitimately needs more than the soft cap.
|
||||
while (heightCache.size > HEIGHT_CACHE_HARD) {
|
||||
const oldest = heightCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
heightCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-project every annotation each rendered frame so marks stay glued to the
|
||||
// world. postRender runs after the camera/tiles update, so positions are exact.
|
||||
const onPostRender = () => projectAll();
|
||||
scene.postRender.addEventListener(onPostRender);
|
||||
|
||||
function color(anno) {
|
||||
return PALETTE[anno.color] || PALETTE.primary;
|
||||
}
|
||||
|
||||
// Best-effort surface height under a coordinate (clamps onto the 3D tiles).
|
||||
// Returns 0 until the tile loads, then caches the validated height.
|
||||
function groundHeight(lon, lat) {
|
||||
const key = `${lon.toFixed(5)},${lat.toFixed(5)}`;
|
||||
const cached = heightCache.get(key);
|
||||
if (cached && cached.settled) {
|
||||
// Mark hot for this frame + bump to most-recently-used so heights for live
|
||||
// marks survive eviction while places we've navigated away from age out.
|
||||
cached.gen = projGen;
|
||||
heightCache.delete(key);
|
||||
heightCache.set(key, cached);
|
||||
return cached.h;
|
||||
}
|
||||
try {
|
||||
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') {
|
||||
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||
heightCache.set(key, { h, settled: true, gen: projGen });
|
||||
trimHeightCache();
|
||||
return h;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* tiles not ready */ }
|
||||
const fallback = cached ? cached.h : 0;
|
||||
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
|
||||
trimHeightCache();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function add(anno) {
|
||||
const c = color(anno);
|
||||
const group = svgEl('g', { class: 'gev-anno', opacity: '0' });
|
||||
const parts = {};
|
||||
|
||||
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
|
||||
// Synthesized (buffered/approximate) areas render DASHED + fainter, with no
|
||||
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
|
||||
parts.poly = svgEl('polygon', {
|
||||
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
||||
fill: c, stroke: c,
|
||||
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
|
||||
'stroke-width': '2.5',
|
||||
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
|
||||
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
|
||||
parts.poly = svgEl('polyline', {
|
||||
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
|
||||
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.poly);
|
||||
parts.dots = anno.path.map(() => {
|
||||
const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' });
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
if (anno.type !== 'label') {
|
||||
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' });
|
||||
parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' });
|
||||
group.appendChild(parts.ringOuter);
|
||||
group.appendChild(parts.ringInner);
|
||||
}
|
||||
parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' });
|
||||
group.appendChild(parts.dot);
|
||||
parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' });
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
}
|
||||
|
||||
// Record the mark BEFORE it touches the live document, then unwind on any
|
||||
// throw from here down (projectAll reaches into Cesium and can fail on a
|
||||
// lost context). Previously a throw past the DOM insert left an ORPHANED
|
||||
// <g>: the engine's rollback only deletes its own map entry, so the next
|
||||
// annotate of the same geometry stacked a fresh mark on top of the corpse.
|
||||
records.set(anno.id, { anno, group, parts });
|
||||
try {
|
||||
svg.appendChild(group);
|
||||
// trigger draw-on / fade-in on the next frame
|
||||
requestAnimationFrame(() => group.classList.add('gev-in'));
|
||||
// The draw-on effect uses a fixed stroke-dasharray of 1400 (see CSS). Any
|
||||
// stroke longer than 1400px (a long arrow/route on a wide window) would keep
|
||||
// a permanent dash gap — the arrowhead detaches from the truncated line (H6).
|
||||
// Once the dashoffset transition finishes the stroke is meant to be fully
|
||||
// drawn, so drop the dasharray entirely then and the stroke reads solid at
|
||||
// any length. Guarded on the specific property so an unrelated transition
|
||||
// (none here, but defensive) doesn't clear it early.
|
||||
for (const drawEl of group.querySelectorAll('.gev-draw')) {
|
||||
drawEl.addEventListener('transitionend', (e) => {
|
||||
if (e.propertyName === 'stroke-dashoffset') {
|
||||
drawEl.style.strokeDasharray = 'none';
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
projectAll();
|
||||
} catch (error) {
|
||||
// Hard, immediate unwind — a mark that never finished has nothing to
|
||||
// fade out, and the caller (engine rollback) must find a clean board.
|
||||
records.delete(anno.id);
|
||||
try { group.remove(); } catch { /* never inserted */ }
|
||||
if (records.size === 0) heightCache.clear();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seat a progressive area upgrade without replacing its SVG group. The hybrid
|
||||
* renderer converts the pending reticle to a centroid label; keeping the group
|
||||
* preserves element identity and prevents the old/new 320 ms fades from overlapping.
|
||||
*/
|
||||
function update(anno, { empty = false } = {}) {
|
||||
const rec = records.get(anno.id);
|
||||
if (!rec) {
|
||||
if (!empty) add(anno);
|
||||
return;
|
||||
}
|
||||
rec.anno = anno;
|
||||
|
||||
if (empty) {
|
||||
for (const child of Array.from(rec.group.children)) child.remove();
|
||||
rec.parts = {};
|
||||
rec.hidden = true;
|
||||
projectAll();
|
||||
return;
|
||||
}
|
||||
rec.hidden = false;
|
||||
|
||||
// Pending areas are initially rendered by this branch as point reticles. On
|
||||
// outline resolution the hybrid renderer changes only their screen proxy to a
|
||||
// label, whose dot/leader/callout already exist; remove the two reticle rings and
|
||||
// keep every remaining node (especially the group and callout) alive.
|
||||
if (anno.type === 'label' && rec.parts.dot) {
|
||||
rec.parts.ringOuter?.remove();
|
||||
rec.parts.ringInner?.remove();
|
||||
delete rec.parts.ringOuter;
|
||||
delete rec.parts.ringInner;
|
||||
rec.parts.dot.setAttribute('r', String(LABEL_DOT_R));
|
||||
projectAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Area-type updates route through the hybrid proxy only.
|
||||
}
|
||||
|
||||
function project(lon, lat) {
|
||||
// Anchor at the real surface height (cached + validated), so marks sit on
|
||||
// the ground/building instead of at sea level. The settle-once cache keeps
|
||||
// it stable (no per-frame jitter that earlier broke nearby projections).
|
||||
const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat));
|
||||
// Reject points behind the camera (they produce wrapped/extreme coords).
|
||||
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null;
|
||||
// Reject points beyond the globe horizon.
|
||||
if (!occluder.isPointVisible(world)) return null;
|
||||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch);
|
||||
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
|
||||
// Reject absurd off-screen projections (anchor not in view).
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
const h = scene.canvas.clientHeight || scene.canvas.height;
|
||||
if (win.x < -w || win.x > 2 * w || win.y < -h || win.y > 2 * h) return null;
|
||||
return { x: win.x, y: win.y };
|
||||
}
|
||||
|
||||
// Tracked-entity z-order: the tracked aircraft is a Cesium billboard in the
|
||||
// CANVAS, which sits BELOW this SVG overlay (z-90, HTML over canvas). So a screen mark
|
||||
// could hide it. After layout, any mark whose ACTUAL projected bounding box (rings,
|
||||
// leader, polygon, route line, callout card — not just the anchor) INTERSECTS the
|
||||
// tracked subject's screen FOOTPRINT is faded. The complete card footprint comes
|
||||
// from the host's ACTUAL painted rectangle after final layout; the billboard extent
|
||||
// is unioned for aircraft/satellites that also own a native tracked graphic.
|
||||
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
|
||||
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
|
||||
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
|
||||
const _scratchTrackedWin = new Cesium.Cartesian2();
|
||||
|
||||
// Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance.
|
||||
function nearFarValue(nfs, dist) {
|
||||
if (dist <= nfs.near) return nfs.nearValue;
|
||||
if (dist >= nfs.far) return nfs.farValue;
|
||||
const t = (dist - nfs.near) / (nfs.far - nfs.near);
|
||||
return nfs.nearValue + t * (nfs.farValue - nfs.nearValue);
|
||||
}
|
||||
// Tracked-subject screen footprint, or null when neither host card nor native
|
||||
// tracked graphic painted. The host rectangle is authoritative for the card.
|
||||
function trackedEntityRect() {
|
||||
const trackedId = activeTrackedReadoutId();
|
||||
const painted = trackedId
|
||||
? overlayPaintRect(TRACKED_OVERLAY_SOURCE_ID, trackedId)
|
||||
: null;
|
||||
let left = painted?.x;
|
||||
let right = painted ? painted.x + painted.w : undefined;
|
||||
let top = painted?.y;
|
||||
let bottom = painted ? painted.y + painted.h : undefined;
|
||||
|
||||
const ent = viewer.trackedEntity;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const world = typeof ent?.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null;
|
||||
const win = world
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin)
|
||||
: null;
|
||||
|
||||
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
||||
const bb = ent?.billboard;
|
||||
if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
|
||||
const baseW = bb.width?.getValue?.(now) ?? 28;
|
||||
const baseH = bb.height?.getValue?.(now) ?? 28;
|
||||
let scale = 1;
|
||||
const sbd = bb.scaleByDistance?.getValue?.(now);
|
||||
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world));
|
||||
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2;
|
||||
const bbLeft = win.x - hw;
|
||||
const bbRight = win.x + hw;
|
||||
const bbTop = win.y - hh;
|
||||
const bbBottom = win.y + hh;
|
||||
left = Number.isFinite(left) ? Math.min(left, bbLeft) : bbLeft;
|
||||
right = Number.isFinite(right) ? Math.max(right, bbRight) : bbRight;
|
||||
top = Number.isFinite(top) ? Math.min(top, bbTop) : bbTop;
|
||||
bottom = Number.isFinite(bottom) ? Math.max(bottom, bbBottom) : bbBottom;
|
||||
}
|
||||
if (![left, right, top, bottom].every(Number.isFinite)) return null;
|
||||
|
||||
const m = TRACKED_BBOX_MARGIN;
|
||||
return { left: left - m, right: right + m, top: top - m, bottom: bottom + m };
|
||||
}
|
||||
|
||||
function projectAll() {
|
||||
if (!records.size) return;
|
||||
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
||||
const trackedRect = trackedEntityRect();
|
||||
occluder.cameraPosition = scene.camera.positionWC;
|
||||
const h = scene.canvas.clientHeight || scene.canvas.height;
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
||||
const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000);
|
||||
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group, parts } = rec;
|
||||
if (rec.hidden) {
|
||||
group.style.display = 'none';
|
||||
continue;
|
||||
}
|
||||
group.setAttribute('opacity', String(anno.alpha ?? 1));
|
||||
|
||||
if (anno.type === 'area' && parts.poly) {
|
||||
const pts = [];
|
||||
let cx = 0; let cy = 0; let n = 0;
|
||||
for (const [lon, lat] of anno.ring) {
|
||||
const p = project(lon, lat);
|
||||
if (!p) continue;
|
||||
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
||||
cx += p.x; cy += p.y; n++;
|
||||
}
|
||||
if (pts.length >= 3) {
|
||||
parts.poly.setAttribute('points', pts.join(' '));
|
||||
group.style.display = '';
|
||||
if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
} else if (anno.type === 'arrow' && parts.path) {
|
||||
const a = project(anno.anchor.lon, anno.anchor.lat);
|
||||
const b = project(anno.to.lon, anno.to.lat);
|
||||
if (a && b) {
|
||||
// gentle arc so the connector reads as a drawn gesture, not a ruler line
|
||||
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2;
|
||||
const dx = b.x - a.x; const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const bow = Math.min(60, len * 0.18);
|
||||
const ctrlX = mx - (dy / len) * bow;
|
||||
const ctrlY = my + (dx / len) * bow;
|
||||
parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`);
|
||||
group.style.display = '';
|
||||
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
||||
if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
} else if (anno.type === 'route' && parts.poly) {
|
||||
const projected = anno.path.map((pt) => project(pt.lon, pt.lat));
|
||||
const pts = [];
|
||||
for (const p of projected) {
|
||||
if (p) pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
||||
}
|
||||
if (pts.length >= 2) {
|
||||
parts.poly.setAttribute('points', pts.join(' '));
|
||||
// dots at each resolvable waypoint; hide the rest
|
||||
parts.dots.forEach((dot, i) => {
|
||||
const p = projected[i];
|
||||
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; }
|
||||
else { dot.style.display = 'none'; }
|
||||
});
|
||||
group.style.display = '';
|
||||
// label at the middle waypoint
|
||||
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)];
|
||||
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
const p = project(anno.anchor.lon, anno.anchor.lat);
|
||||
if (p) {
|
||||
group.style.display = '';
|
||||
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
||||
if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); }
|
||||
if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); }
|
||||
if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); }
|
||||
if (parts.label) {
|
||||
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
|
||||
const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale;
|
||||
positionCallout(parts.label, lx, ly, false);
|
||||
if (parts.leader) {
|
||||
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1));
|
||||
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1));
|
||||
parts.label._leader = parts.leader; // so de-collision can re-point it
|
||||
}
|
||||
}
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep clustered callouts readable.
|
||||
const placed = [];
|
||||
for (const rec of records.values()) {
|
||||
const cal = rec.parts.label;
|
||||
if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) {
|
||||
placed.push(cal);
|
||||
}
|
||||
}
|
||||
if (placed.length > 1) decollideCallouts(placed);
|
||||
|
||||
// Tracked-entity z-order — runs AFTER all geometry + callout de-collision so each
|
||||
// group's bbox reflects its final on-screen extent. Any mark whose bbox INTERSECTS
|
||||
// the tracked entity's screen footprint is faded toward FULLY HIDDEN (target 0, not a
|
||||
// partial dim) so the tracked plane/ship reads as genuinely ON TOP. The fade is EASED
|
||||
// per frame (no abrupt pop as the entity crosses a mark) and reverses smoothly when
|
||||
// it moves off. Every screen mark here is point-like (pins/labels/reticles/arrows) —
|
||||
// the big area/route footprints live in the world layer — so fully hiding an
|
||||
// overlapped one is safe and not jarring.
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group } = rec;
|
||||
if (group.style.display === 'none') continue;
|
||||
const cur = rec._trackedFade ?? 1;
|
||||
let overlapping = false;
|
||||
if (trackedRect) {
|
||||
let bb = null;
|
||||
try { bb = group.getBBox(); } catch { bb = null; }
|
||||
if (bb && (bb.width !== 0 || bb.height !== 0)) {
|
||||
// HYSTERESIS: a mark that's already hidden must clear the rect by
|
||||
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
|
||||
// as soon as it touches. The dead-band stops the overlap test from flip-flopping
|
||||
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
|
||||
// motion), which was the source of the flicker.
|
||||
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
|
||||
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m
|
||||
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m;
|
||||
}
|
||||
}
|
||||
const target = overlapping ? 0 : 1;
|
||||
const next = cur + (target - cur) * TRACKED_FADE_EASE;
|
||||
rec._trackedFade = Math.abs(next - target) < 0.02 ? target : next;
|
||||
// Only override when faded — otherwise leave the base alpha (set above) intact.
|
||||
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
|
||||
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
|
||||
if (rec._trackedFade !== 1) {
|
||||
group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
const rec = records.get(anno.id);
|
||||
if (!rec) return;
|
||||
rec.group.classList.remove('gev-in');
|
||||
rec.group.classList.add('gev-out');
|
||||
const node = rec.group;
|
||||
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360);
|
||||
records.delete(anno.id);
|
||||
// Board emptied → drop accumulated height samples (natural reset point).
|
||||
if (records.size === 0) heightCache.clear();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
// Positioning is driven by the postRender loop; nothing to batch here.
|
||||
projectAll();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ }
|
||||
try { layer.remove(); } catch { /* gone */ }
|
||||
records.clear();
|
||||
heightCache.clear();
|
||||
}
|
||||
|
||||
return { add, update, remove, sync, destroy };
|
||||
}
|
||||
|
||||
// --- SVG helpers ------------------------------------------------------------
|
||||
|
||||
function buildOverlay() {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'gev-screen-whiteboard';
|
||||
const svg = document.createElementNS(SVGNS, 'svg');
|
||||
svg.setAttribute('class', 'gev-screen-whiteboard-svg');
|
||||
svg.setAttribute('preserveAspectRatio', 'none');
|
||||
const defs = document.createElementNS(SVGNS, 'defs');
|
||||
// subtle hand-drawn wobble for strokes
|
||||
defs.innerHTML = `
|
||||
<filter id="gev-sketch" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.014" numOctaves="2" seed="7" result="n"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.2" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>`;
|
||||
svg.appendChild(defs);
|
||||
layer.appendChild(svg);
|
||||
return { layer, svg, defs };
|
||||
}
|
||||
|
||||
function makeCallout(text, c) {
|
||||
if (!text) return null;
|
||||
const node = document.createElementNS(SVGNS, 'g');
|
||||
node.setAttribute('class', 'gev-anno-callout');
|
||||
const rect = document.createElementNS(SVGNS, 'rect');
|
||||
rect.setAttribute('rx', '5');
|
||||
rect.setAttribute('class', 'gev-anno-card');
|
||||
const t = document.createElementNS(SVGNS, 'text');
|
||||
t.setAttribute('class', 'gev-anno-text');
|
||||
t.setAttribute('x', '9');
|
||||
t.setAttribute('y', '16');
|
||||
t.textContent = text;
|
||||
const accent = document.createElementNS(SVGNS, 'rect');
|
||||
accent.setAttribute('class', 'gev-anno-accent');
|
||||
accent.setAttribute('width', '3');
|
||||
accent.setAttribute('rx', '1.5');
|
||||
accent.setAttribute('fill', c);
|
||||
node.appendChild(rect);
|
||||
node.appendChild(accent);
|
||||
node.appendChild(t);
|
||||
const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false };
|
||||
// size the card to the text once it is in the DOM
|
||||
requestAnimationFrame(() => sizeCallout(callout));
|
||||
return callout;
|
||||
}
|
||||
|
||||
function sizeCallout(callout) {
|
||||
try {
|
||||
const bbox = callout.text.getBBox();
|
||||
const w = Math.max(24, bbox.width + 18);
|
||||
const h = Math.max(22, bbox.height + 10);
|
||||
callout.rect.setAttribute('width', String(w));
|
||||
callout.rect.setAttribute('height', String(h));
|
||||
callout.accent.setAttribute('height', String(h - 8));
|
||||
callout.accent.setAttribute('x', '0');
|
||||
callout.accent.setAttribute('y', '4');
|
||||
callout.width = w;
|
||||
callout.height = h;
|
||||
callout.sized = true;
|
||||
} catch { /* not laid out yet */ }
|
||||
}
|
||||
|
||||
function positionCallout(callout, x, y, center) {
|
||||
if (!callout.sized) sizeCallout(callout);
|
||||
const tx = center ? x - callout.width / 2 : x;
|
||||
callout._x = tx;
|
||||
callout._y = y;
|
||||
callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge overlapping callout cards apart so labels stay readable when several
|
||||
* marks cluster on screen. Simple top-down sweep: each card that overlaps an
|
||||
* earlier one is pushed just below it. O(n²) but n is small.
|
||||
*/
|
||||
function decollideCallouts(callouts) {
|
||||
callouts.sort((a, b) => a._y - b._y);
|
||||
for (let i = 0; i < callouts.length; i++) {
|
||||
const a = callouts[i];
|
||||
for (let j = 0; j < i; j++) {
|
||||
const b = callouts[j];
|
||||
const overlapX = a._x < b._x + b.width + 6 && a._x + a.width + 6 > b._x;
|
||||
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
|
||||
if (overlapX && overlapY) {
|
||||
a._y = b._y + b.height + 5;
|
||||
a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`);
|
||||
if (a._leader) {
|
||||
a._leader.setAttribute('x2', a._x.toFixed(1));
|
||||
a._leader.setAttribute('y2', a._y.toFixed(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureArrowMarker(defs, key, c) {
|
||||
if (defs.querySelector(`#gev-arrow-${key}`)) return;
|
||||
const marker = document.createElementNS(SVGNS, 'marker');
|
||||
marker.setAttribute('id', `gev-arrow-${key}`);
|
||||
marker.setAttribute('viewBox', '0 0 10 10');
|
||||
marker.setAttribute('refX', '8');
|
||||
marker.setAttribute('refY', '5');
|
||||
marker.setAttribute('markerWidth', '7');
|
||||
marker.setAttribute('markerHeight', '7');
|
||||
marker.setAttribute('orient', 'auto-start-reverse');
|
||||
const path = document.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
||||
path.setAttribute('fill', c);
|
||||
marker.appendChild(path);
|
||||
defs.appendChild(marker);
|
||||
}
|
||||
|
||||
function svgEl(tag, attrs) {
|
||||
const el = document.createElementNS(SVGNS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('gev-screen-whiteboard-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'gev-screen-whiteboard-styles';
|
||||
style.textContent = `
|
||||
.gev-screen-whiteboard { position: fixed; inset: 0; pointer-events: none; z-index: 90; }
|
||||
.gev-screen-whiteboard-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
/* No CSS opacity transition here on purpose: group opacity is driven per-frame in JS
|
||||
(the 260ms fade-in via computeAlpha, and the tracked-entity z-order ease). A CSS
|
||||
transition fought those per-frame writes and made the tracked-fade opacity oscillate
|
||||
(flicker). Fade-OUT keeps its own transition via .gev-anno.gev-out below. */
|
||||
.gev-anno-ring { filter: drop-shadow(0 0 6px currentColor); }
|
||||
.gev-anno-dot { filter: drop-shadow(0 0 5px rgba(255,255,255,0.6)); }
|
||||
.gev-anno-area { filter: drop-shadow(0 0 5px currentColor); }
|
||||
.gev-anno-arrow { filter: drop-shadow(0 0 4px currentColor); stroke-linecap: round; }
|
||||
.gev-anno-leader { stroke-dasharray: 2 3; }
|
||||
.gev-anno-card { fill: rgba(8,18,28,0.78); stroke: rgba(255,255,255,0.14); stroke-width: 1; }
|
||||
.gev-anno-text { fill: #eaf6ff; font: 600 13px "JetBrains Mono", ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
/* draw-on: outlined shapes reveal their stroke */
|
||||
.gev-draw { stroke-dasharray: 1400; stroke-dashoffset: 1400; }
|
||||
.gev-anno.gev-in .gev-draw { transition: stroke-dashoffset 900ms ease-out; stroke-dashoffset: 0; }
|
||||
/* opacity-only fade-in: a CSS transform here would override the SVG transform
|
||||
attribute used to POSITION the callout and snap it to 0,0. */
|
||||
.gev-anno-callout { opacity: 0; }
|
||||
.gev-anno.gev-in .gev-anno-callout { animation: gev-pop 320ms ease-out both; }
|
||||
.gev-anno.gev-out { opacity: 0 !important; transition: opacity 320ms ease; }
|
||||
.gev-pulse { animation: gev-ring-pulse 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes gev-ring-pulse { 0%,100% { opacity: 0.9; stroke-width: 2; } 50% { opacity: 0.35; stroke-width: 3.5; } }
|
||||
@keyframes gev-pop { from { opacity: 0; } to { opacity: 1; } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
this.names = new Set();
|
||||
}
|
||||
|
||||
reset(value) {
|
||||
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
for (const name of names) this.names.add(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
for (const name of names) this.names.delete(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.names.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this.classList = new FakeClassList(this);
|
||||
this.textContent = '';
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.classList.reset(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.setAttribute('class', value);
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.getAttribute('class') || '';
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
this.setAttribute('id', value);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.getAttribute('id') || '';
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) this.children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parentNode?.removeChild(this);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (className && child.classList.contains(className)) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
|
||||
getBBox() {
|
||||
return { x: 0, y: 0, width: 40, height: 16 };
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeDocument() {
|
||||
const head = new FakeElement('head');
|
||||
const body = new FakeElement('body');
|
||||
const roots = [head, body];
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
||||
getElementById(id) {
|
||||
let found = null;
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute('id') === id) found = node;
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findAnnotationGroup(document) {
|
||||
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
||||
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
||||
return {
|
||||
svg,
|
||||
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
||||
};
|
||||
}
|
||||
|
||||
test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.ZERO,
|
||||
directionWC: Cesium.Cartesian3.ZERO,
|
||||
positionCartographic: { height: 1000 },
|
||||
};
|
||||
const sampledAnchors = [];
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight(world) {
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(world);
|
||||
sampledAnchors.push([
|
||||
Number(Cesium.Math.toDegrees(cartographic.longitude).toFixed(4)),
|
||||
Number(Cesium.Math.toDegrees(cartographic.latitude).toFixed(4)),
|
||||
]);
|
||||
return world;
|
||||
},
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
const anno = {
|
||||
id: 'anno-fb3',
|
||||
type: 'area',
|
||||
color: 'primary',
|
||||
label: 'Texas',
|
||||
alpha: 1,
|
||||
anchor: { lon: -99, lat: 31, height: 0 },
|
||||
ring: null,
|
||||
};
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
||||
|
||||
const labelProxy = Object.assign(Object.create(anno), {
|
||||
type: 'label',
|
||||
ring: null,
|
||||
anchor: { lon: -97.5, lat: 31.2, height: 0 },
|
||||
});
|
||||
renderer.update(labelProxy);
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
|
||||
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
|
||||
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
|
||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
||||
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
||||
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const paintRectCalls = [];
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{
|
||||
activeTrackedReadoutId: () => 'installations:test',
|
||||
overlayPaintRect(sourceId, entryId) {
|
||||
paintRectCalls.push({ sourceId, entryId });
|
||||
return { x: -10, y: -10, w: 120, h: 80 };
|
||||
},
|
||||
},
|
||||
);
|
||||
renderer.add({
|
||||
id: 'anno-overlap',
|
||||
type: 'label',
|
||||
color: 'primary',
|
||||
label: 'OVERLAP',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
});
|
||||
const { group } = findAnnotationGroup(globalThis.document);
|
||||
assert.deepEqual(paintRectCalls.at(-1), {
|
||||
sourceId: 'tracked',
|
||||
entryId: 'installations:test',
|
||||
});
|
||||
assert.ok(
|
||||
Number(group.getAttribute('opacity')) < 1,
|
||||
'final annotation bbox fades against the complete painted card rectangle',
|
||||
);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
// ── Partial-add unwind (second review) ───────────────────────────────────────
|
||||
//
|
||||
// add() inserts the group and records it, then does more live-document work
|
||||
// (draw-on wiring, the first projection pass). A throw in that tail used to
|
||||
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
|
||||
// deletes its own map entry — so re-annotating the same place stacked a second
|
||||
// mark over the corpse.
|
||||
|
||||
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
const anno = {
|
||||
id: 'anno-partial-screen',
|
||||
type: 'pin',
|
||||
color: 'primary',
|
||||
label: 'Capitol',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
};
|
||||
|
||||
// The group is in the document and recorded by the time add() reaches its
|
||||
// first projection pass — so a throw there is real partial state, not a
|
||||
// clean bail-out.
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
|
||||
assert.throws(() => renderer.add(anno), /projection failed/);
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(
|
||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
||||
0,
|
||||
'the half-built group must not stay in the document',
|
||||
);
|
||||
|
||||
// The id is free again: a retry draws exactly one mark, and remove() still
|
||||
// owns it (a leaked record would leave a second group behind).
|
||||
renderer.add(anno);
|
||||
assert.equal(
|
||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
||||
1,
|
||||
'the retry must draw one mark, not stack over an orphan',
|
||||
);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* World-space annotation renderer (Direction A).
|
||||
*
|
||||
* Draws annotations as native Cesium entities anchored to lon/lat. Because they
|
||||
* live in the 3D scene they track the camera, sit at the right depth, and are
|
||||
* occluded by the photoreal buildings the way a real marker would be.
|
||||
*
|
||||
* IMPORTANT — drawing on Google Photorealistic 3D Tiles:
|
||||
* The Cesium globe is hidden, so there is no terrain to clamp to. Ground
|
||||
* geometry (areas, rings, connectors) is draped onto the photoreal tiles with
|
||||
* `classificationType: CESIUM_3D_TILE`; points and labels clamp to the tile
|
||||
* surface with `heightReference: CLAMP_TO_GROUND` (which requires the tileset
|
||||
* to have `enableCollision = true`, set in initAnnotations). This keeps marks
|
||||
* sitting ON the world instead of buried at sea level.
|
||||
*
|
||||
* Live alpha (fade in/out) and pulsing are driven by CallbackProperty so they
|
||||
* animate every render without the engine touching them per frame.
|
||||
*
|
||||
* Renderer contract (shared with the screen-space renderer):
|
||||
* add(anno) / remove(anno) / sync(map) / destroy()
|
||||
*/
|
||||
|
||||
const PALETTE = {
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
};
|
||||
|
||||
const CLASSIFY = Cesium.ClassificationType.CESIUM_3D_TILE;
|
||||
const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
|
||||
|
||||
export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
// from performance.now() inside FlowMaterialProperty.getValue (which Cesium calls each
|
||||
// rendered frame with the live uniforms object), so the dashes flow with no extra
|
||||
// per-frame bookkeeping.
|
||||
ensureFlowFabricRegistered();
|
||||
|
||||
function colorFor(anno) {
|
||||
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
|
||||
}
|
||||
|
||||
// Target-ring radius (meters) scaled to camera height so it reads at any
|
||||
// altitude. Read once per frame for BOTH ellipse axes — camera height is
|
||||
// constant within a frame, so semiMajor === semiMinor always holds.
|
||||
function ringRadius() {
|
||||
const h = viewer.camera.positionCartographic?.height ?? 1000;
|
||||
return Math.max(14, Math.min(170, h * 0.03));
|
||||
}
|
||||
|
||||
// A live color that follows the annotation's fade alpha and an optional pulse.
|
||||
function liveColor(anno, base, { alpha = 0.9, pulse = false } = {}) {
|
||||
return new Cesium.CallbackProperty(() => {
|
||||
const a = (anno.alpha ?? 1) * alpha * (pulse ? pulseFactor() : 1);
|
||||
return base.withAlpha(Math.max(0, Math.min(1, a)));
|
||||
}, false);
|
||||
}
|
||||
|
||||
function add(anno) {
|
||||
const base = colorFor(anno);
|
||||
// Published to the mark BEFORE anything is added, and mutated in place as
|
||||
// each entity lands: a mid-add failure (bad geometry, lost context) must
|
||||
// leave the entities that DID land visible to remove(), or the rollback
|
||||
// path cannot reach them and they stay on the globe forever.
|
||||
const entities = [];
|
||||
anno._entities = entities;
|
||||
|
||||
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
|
||||
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
|
||||
// volume that tints the real photogrammetry mesh (dome, walls, roof —
|
||||
// everything inside the column) amber and pulses. A flat footprint
|
||||
// extrusion is only 2.5D and misses domes; classification colors the
|
||||
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
|
||||
const buffered = bufferRing(anno.ring, 3);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
||||
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
|
||||
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
|
||||
const baseH = groundH - 3;
|
||||
// Tall headroom is free: classification only colors where tiles exist, so
|
||||
// empty air above the roof isn't tinted — this just guarantees we cover
|
||||
// under-tagged building heights.
|
||||
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
|
||||
// 1) Classification volume — tints the mesh amber, pulsing.
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
|
||||
},
|
||||
}));
|
||||
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
||||
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
||||
// per (often 100+) ring vertex.
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}));
|
||||
// 3) Crisp draped base outline so the footprint reads on the ground.
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 3,
|
||||
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
|
||||
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
// A real path (street-following) draped on the 3D tiles, with dashes that
|
||||
// FLOW toward the destination (custom st.s + time material; works on the
|
||||
// clamped/classified ground polyline).
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.path.flatMap((p) => [p.lon, p.lat]),
|
||||
);
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
const positions = [
|
||||
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
|
||||
];
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 16,
|
||||
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
if (anno.label) {
|
||||
const mid = {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
};
|
||||
entities.push(dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||
if (anno.type !== 'label') {
|
||||
// Radius scales with camera height so the ring reads at any altitude.
|
||||
// semiMajor === semiMinor is required AND must hold every frame, so both
|
||||
// axes read the SAME ringRadius() (camera height is constant per frame).
|
||||
entities.push(dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
ellipse: {
|
||||
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
|
||||
outline: false,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
}
|
||||
entities.push(labelMarker(anno, base, { point: true }));
|
||||
}
|
||||
}
|
||||
|
||||
// A clamped point + (optional) label that sits on the tile surface.
|
||||
function labelMarker(anno, base, { point }) {
|
||||
return dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
point: point ? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
} : undefined,
|
||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function labelGraphic(anno, base) {
|
||||
return {
|
||||
text: anno.label,
|
||||
font: '600 14px "Inter", system-ui, sans-serif',
|
||||
fillColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.BLACK, { alpha: 0.85 }),
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
showBackground: true,
|
||||
backgroundColor: new Cesium.CallbackProperty(
|
||||
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
|
||||
false,
|
||||
),
|
||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(500, 1.05, 14000, 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
dataSource.entities.remove(entity);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
anno._entities = null;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
/* scene torn down */
|
||||
}
|
||||
}
|
||||
|
||||
return { add, remove, sync, destroy };
|
||||
}
|
||||
|
||||
function pulseFactor() {
|
||||
// 0.6 .. 1.0 sinusoid at ~0.8 Hz
|
||||
return 0.8 + 0.2 * Math.sin(performance.now() * 0.005);
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom Fabric polyline material whose dashes flow toward the destination.
|
||||
* `materialInput.st.s` is the along-line coordinate (0 = origin, 1 = destination),
|
||||
* so `fract(s*repeat - time*speed)` scrolls the pattern toward the end. Works on
|
||||
* a clamped/classified ground polyline (PolylineMaterialAppearance supports it).
|
||||
*/
|
||||
function makeRouteFlowMaterial(colorCss) {
|
||||
return new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'GevRouteFlow',
|
||||
uniforms: {
|
||||
color: Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95),
|
||||
time: 0.0,
|
||||
repeat: 64.0, // dash cells along the whole route
|
||||
duty: 0.46, // fraction of each cell that is "on"
|
||||
speed: 0.55, // cells per second toward the destination
|
||||
},
|
||||
source: `
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material m = czm_getDefaultMaterial(materialInput);
|
||||
float s = materialInput.st.s; // 0 origin -> 1 dest
|
||||
float flow = fract(s * repeat - time * speed); // scroll toward dest
|
||||
float on = smoothstep(duty + 0.08, duty - 0.08, flow);
|
||||
// keep a faint baseline so the whole route stays readable between dashes
|
||||
float a = max(on, 0.18);
|
||||
m.diffuse = color.rgb;
|
||||
m.emission = color.rgb * on * 0.9; // glow on the lit cells
|
||||
m.alpha = color.a * a;
|
||||
return m;
|
||||
}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let _flowFabricRegistered = false;
|
||||
/** Register the GevRouteFlow fabric ONCE so Cesium's `Material.fromType('GevRouteFlow')`
|
||||
* can build the material the render pipeline uses. Constructing one Material with the
|
||||
* fabric caches it under its type name. */
|
||||
function ensureFlowFabricRegistered() {
|
||||
if (_flowFabricRegistered) return;
|
||||
makeRouteFlowMaterial('#ffffff'); // side effect: registers the 'GevRouteFlow' type
|
||||
_flowFabricRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* MaterialProperty for the animated route. Cesium builds the rendered Material once
|
||||
* from getType() (our registered GevRouteFlow fabric), then EACH FRAME calls
|
||||
* getValue(time, material.uniforms) and uses whatever we write INTO that uniforms
|
||||
* object — it ignores the return value. So getValue writes color/time/repeat/duty/
|
||||
* speed straight onto `result` (the live uniforms). Writing the animated `time` here
|
||||
* is what actually makes the dashes flow on the GPU. (The prior versions either
|
||||
* returned a standalone Material Cesium never rendered, or treated `result` as a
|
||||
* Material — both left the real uniforms untouched, so nothing animated.)
|
||||
*/
|
||||
function FlowMaterialProperty(colorCss) {
|
||||
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
|
||||
this._definitionChanged = new Cesium.Event();
|
||||
}
|
||||
Object.defineProperties(FlowMaterialProperty.prototype, {
|
||||
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
|
||||
definitionChanged: { get() { return this._definitionChanged; } },
|
||||
});
|
||||
FlowMaterialProperty.prototype.getType = function getType() {
|
||||
return 'GevRouteFlow';
|
||||
};
|
||||
FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
|
||||
// `result` IS the live uniforms object Cesium renders — write into it directly.
|
||||
// `time` is read straight from the wall clock so the dashes flow every rendered
|
||||
// frame (the scene renders continuously; no requestRenderMode here).
|
||||
if (!Cesium.defined(result)) result = {};
|
||||
result.color = this._color;
|
||||
result.time = performance.now() / 1000; // the per-frame animated value
|
||||
result.repeat = 64.0;
|
||||
result.duty = 0.46;
|
||||
result.speed = 0.55;
|
||||
return result;
|
||||
};
|
||||
FlowMaterialProperty.prototype.equals = function equals(other) {
|
||||
return this === other;
|
||||
};
|
||||
|
||||
/** Evenly down-sample a [[lon,lat],...] ring to at most n points (keeps shape). */
|
||||
function decimateRing(ring, n) {
|
||||
if (ring.length <= n) return ring;
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) out.push(ring[Math.floor((i * ring.length) / n)]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
|
||||
* the centroid would clamp onto the roof, so we probe several points beyond the
|
||||
* footprint radius and take a low percentile (≈ ground). Returns null if the
|
||||
* tiles under those points aren't loaded yet.
|
||||
*/
|
||||
function sampleGroundOutside(scene, ring) {
|
||||
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
|
||||
let clon = 0;
|
||||
let clat = 0;
|
||||
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
|
||||
clon /= ring.length;
|
||||
clat /= ring.length;
|
||||
const latS = 111320;
|
||||
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
|
||||
let maxR = 0;
|
||||
for (const [lon, lat] of ring) {
|
||||
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
|
||||
}
|
||||
const out = maxR * 1.5 + 12;
|
||||
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
|
||||
const samples = [];
|
||||
for (const [dx, dy] of dirs) {
|
||||
const lon = clon + (dx * out) / lonS;
|
||||
const lat = clat + (dy * out) / latS;
|
||||
try {
|
||||
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
|
||||
}
|
||||
} catch { /* tile not ready */ }
|
||||
}
|
||||
if (!samples.length) return null;
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length * 0.3)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate a [[lon,lat],...] ring outward from its centroid by `meters`, so an
|
||||
* extruded building volume encloses the photogrammetry mesh instead of slicing
|
||||
* through its edges. Radial buffer — fine for compact building footprints.
|
||||
*/
|
||||
function bufferRing(ring, meters) {
|
||||
let clon = 0;
|
||||
let clat = 0;
|
||||
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
|
||||
clon /= ring.length;
|
||||
clat /= ring.length;
|
||||
const latScale = 111320;
|
||||
const lonScale = latScale * Math.cos(Cesium.Math.toRadians(clat));
|
||||
return ring.map(([lon, lat]) => {
|
||||
const dx = (lon - clon) * lonScale;
|
||||
const dy = (lat - clat) * latScale;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
const k = (d + meters) / d;
|
||||
return [clon + (dx * k) / lonScale, clat + (dy * k) / latScale];
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user