Release God's Eye View as open source

This commit is contained in:
Bilawal Sidhu committed 2026-08-24 17:02:16 -05:00
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
+670
View File
@@ -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');
});
+213
View File
@@ -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);
}
+118
View File
@@ -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
+364
View File
@@ -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',
);
});
+150
View File
@@ -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();
});
+22
View File
@@ -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;
}
+674
View File
@@ -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();
});
+435
View File
@@ -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];
});
}