merge main f13c60c into feat/voice-outlines

reconcile voice outlines with main f13c60c, retaining German/Japanese,
Operator HUD retirement and Context/Radio cleanup, named-building
containment, courtyard-hole, non-Latin identity, attribution, cancellation
and antimeridian repairs

keep outline callouts above the open voice preview when an above-panel
slot fits without colliding with another callout,
and clip the compact dock location label on narrow screens
This commit is contained in:
manjunath committed 2026-10-30 19:02:47 +05:30
commit 578ebdc030
449 files changed
+30400 -3429

No files matched your search

+149 -3
View File
@@ -12,7 +12,11 @@ import {
sampleGroundHeight,
} from './annotationResolver.js';
import { ringCentroid } from './drawMode.js';
import { phaseTiming } from '../sources/phaseTiming.js';
import {
createWorkTrace,
phaseTiming,
recordWorkSlice,
} from '../sources/phaseTiming.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
@@ -140,6 +144,7 @@ export function createAnnotationEngine({
// draw after a clear() (or after a newer clearPrevious) has wiped the board.
let generation = 0;
const activeControllers = new Set();
const buildingInstalls = new WeakMap();
// Outline upgrades are intentionally narrower than the concurrent anchor phase:
// points render immediately, then expensive Overpass continuations drain FIFO.
const outlineQueue = [];
@@ -297,6 +302,23 @@ export function createAnnotationEngine({
};
}
function beginBuildingInstall(anno) {
let settle;
const record = {
settled: new Promise((resolve) => {
settle = resolve;
}),
accepted: false,
error: null,
};
buildingInstalls.set(anno, record);
record.finish = () => {
if (buildingInstalls.get(anno) === record) buildingInstalls.delete(anno);
settle();
};
return record;
}
function installedOutcome(anno, outcome) {
if (outcome?.status === 'cancelled')
throw new Error('Annotation installation cancelled');
@@ -305,6 +327,7 @@ export function createAnnotationEngine({
anno.ring = null;
anno.polygons = null;
anno.footprintKind = null;
anno.outlineCredit = null;
anno.outlineUnavailable = true;
renderer.update(anno);
}
@@ -400,6 +423,7 @@ export function createAnnotationEngine({
ensureTicking();
const index = results.length;
results.push(null);
const record = beginBuildingInstall(anno);
installations.push(
Promise.resolve(completion)
.then((outcome) => {
@@ -420,8 +444,11 @@ export function createAnnotationEngine({
myGen,
controller,
);
record.accepted = true;
})
.catch((error) => {
// i18n-ignore: structured annotation tool diagnostic, not UI text.
record.error = error?.message || 'Annotation installation failed';
anno.pendingOutline = false;
anno.outlineUnavailable = true;
try {
@@ -436,7 +463,8 @@ export function createAnnotationEngine({
spec,
error?.message || 'Annotation installation failed',
);
}),
})
.finally(record.finish),
);
};
let firstAnchor = null;
@@ -501,6 +529,7 @@ export function createAnnotationEngine({
anno.footprintKind = dup.footprintKind || null;
anno.buildingHeight = dup.buildingHeight || null;
anno.synthesized = Boolean(dup.synthesized);
anno.outlineCredit = dup.outlineCredit || null;
anno.anchor = { ...dup.anchor };
}
if (anno.ring?.length >= 3 && anno.footprintKind === 'building') {
@@ -657,6 +686,38 @@ export function createAnnotationEngine({
dup.fadeStart = null;
ids.push(dup.id);
if (!firstAnchor) firstAnchor = dup;
let record = buildingInstalls.get(dup);
if (record) {
const index = results.length;
results.push(null);
installations.push(
(async () => {
do {
await record.settled;
if (
superseded() ||
annotations.get(dup.id) !== dup ||
!record.accepted
) {
results[index] = failResult(
spec,
record.error ||
'Annotation installation failed or superseded',
);
return;
}
// Follow only a physical install already started by the owner,
// never its progressive network work or a replacement's task.
record = buildingInstalls.get(dup);
} while (record);
results[index] = {
...okResult(dup, resolved, dup.id),
duplicate: true,
};
})(),
);
continue;
}
// A re-narration is also a RETRY: if the existing mark never got its outline
// (transient Overpass miss), attach this call's deferred resolver to it.
if (!dup.ring && typeof resolved.resolveOutline === 'function') {
@@ -973,6 +1034,9 @@ export function createAnnotationEngine({
async function runOutlineUpgrade(task) {
const { anno, resolveOutline, myGen, controller } = task;
let installRecord;
let workTrace;
let workStart = null;
try {
let fp = null;
// Geometry upgrade is best-effort — the point stays on any failure. A TRANSIENT
@@ -996,10 +1060,18 @@ export function createAnnotationEngine({
let completionStart = null;
if (anno.outlineUnavailable) renderer.update(anno);
if (fp && !anno.outlineUnavailable) {
if (import.meta.env?.DEV && fp.footprintKind === 'building') {
workTrace = createWorkTrace();
if (workTrace) {
workTrace.callerStart = installStart;
workStart = installStart;
}
}
anno.pendingOutline = true;
anno.ring = fp.ring;
anno.polygons = fp.polygons || null;
anno.footprintKind = fp.footprintKind || null;
anno.outlineCredit = fp.outlineCredit || null;
anno.buildingHeight = fp.buildingHeight || null;
anno.synthesized = Boolean(fp.synthesized);
// Re-center on the footprint centroid, as the inline path always did.
@@ -1012,12 +1084,22 @@ export function createAnnotationEngine({
}
// Re-route: ring presence decides world-drape vs screen-space in the renderer.
const ownership = installOptions(anno, controller, myGen);
if (workTrace) ownership.workTrace = workTrace;
const completion = renderer.update(anno, ownership);
if (completion?.then) {
installRecord = beginBuildingInstall(anno);
schedulingMs = performance.now() - installStart;
if (workTrace) {
recordWorkSlice(workTrace, 'engine-schedule', workStart);
workStart = null;
}
groundInstallation = await completion;
completionStart = performance.now();
if (!ownership.isCurrent()) return;
if (workTrace) workStart = completionStart;
if (!ownership.isCurrent()) {
if (workTrace) workTrace.status = 'cancelled';
return;
}
installedOutcome(anno, groundInstallation);
} else anno.pendingOutline = false;
// GEOMETRY dedup, now that geometry is known: two DIFFERENT targets can resolve
@@ -1083,6 +1165,10 @@ export function createAnnotationEngine({
// Final outcome → the voice layer (place names ride as structured DATA fields).
// 'failed' covers both the definitive no-polygon and an exhausted transient
// retry — either way the mark honestly stays a point.
if (workTrace && workStart !== null) {
recordWorkSlice(workTrace, 'engine-process', workStart);
workStart = null;
}
emitOutlineEvent({
id: anno.id,
label: anno.label || null,
@@ -1097,7 +1183,18 @@ export function createAnnotationEngine({
: {}),
...(fp && anno.synthesized ? { approximate: true } : {}),
});
if (workTrace) workStart = performance.now();
if (installRecord) installRecord.accepted = true;
} catch (error) {
if (workTrace) {
if (workStart !== null)
recordWorkSlice(workTrace, 'engine-error', workStart);
workTrace.status = controller.signal.aborted ? 'cancelled' : 'failed';
workStart = performance.now();
}
if (installRecord)
// i18n-ignore: structured annotation tool diagnostic, not UI text.
installRecord.error = error?.message || 'Outline installation failed';
if (
myGen === generation &&
!controller.signal.aborted &&
@@ -1110,7 +1207,9 @@ export function createAnnotationEngine({
anno.ring = null;
anno.polygons = null;
anno.footprintKind = null;
anno.outlineCredit = null;
renderer.add(anno);
if (installRecord) installRecord.accepted = true;
} catch {
try {
removeRendered(anno);
@@ -1120,6 +1219,10 @@ export function createAnnotationEngine({
}
syncAnnotationHold();
}
if (workTrace) {
recordWorkSlice(workTrace, 'engine-recovery', workStart);
workStart = null;
}
emitOutlineEvent({
id: anno.id,
label: anno.label || null,
@@ -1127,12 +1230,54 @@ export function createAnnotationEngine({
status: 'failed',
message: 'Outline installation failed',
});
if (workTrace) workStart = performance.now();
}
} finally {
if (workTrace && workStart === null) workStart = performance.now();
if (anno._outlineTask === task) anno._outlineTask = null;
releaseController(controller);
activeOutlineUpgrades -= 1;
if (workTrace) {
recordWorkSlice(workTrace, 'engine-finalize', workStart);
workStart = null;
}
drainOutlineQueue();
if (workTrace) workStart = performance.now();
installRecord?.finish();
if (workTrace) {
if (workTrace.status === 'pending') {
const rendererStatus =
workTrace.rendererStatus || workTrace.samplerStatus || 'failed';
workTrace.status =
rendererStatus !== 'installed'
? rendererStatus
: installRecord?.accepted &&
workTrace.samplerStatus === 'installed' &&
!anno.outlineUnavailable &&
!controller.signal.aborted &&
myGen === generation &&
annotations.get(anno.id) === anno
? 'installed'
: 'cancelled';
}
workTrace.completedAt = performance.now();
recordWorkSlice(
workTrace,
'engine-settle',
workStart,
workTrace.completedAt,
);
try {
phaseTiming(
'install-work',
workTrace.callerStart,
workTrace,
'outlines',
);
} catch {
/* Development telemetry must not change the installation outcome. */
}
}
}
}
@@ -1328,6 +1473,7 @@ export function createAnnotationEngine({
// more than the main ring (Hawaii's islands, Berlin inside Brandenburg).
polygons: resolved.polygons || null,
footprintKind: resolved.footprintKind || null, // 'building' | 'area'
outlineCredit: resolved.outlineCredit || null,
buildingHeight: resolved.buildingHeight || null, // meters, for extruded volume
synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render
// Progressive outline: the anchor is placed, the footprint is still resolving —
+114
View File
@@ -910,6 +910,100 @@ test('duplicate pending building results reflect final failure rather than a dea
assert.equal(engine.count(), 0);
});
test('separate duplicate callers observe the original building install outcome', async (t) => {
installAnimationFrameStubs(t);
for (const outcome of ['installed', 'failed', 'timeout', 'clear']) {
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {},
renderer: h.renderer,
resolveTarget: async () => ({ ...BUILDING_FP }),
});
const spec = { type: 'area', target: 'Capitol', label: 'Same' };
const first = engine.annotate([spec]);
await flushMicrotasks();
let reported = false;
const second = engine.annotate([spec]).then((result) => {
reported = true;
return result;
});
await flushMicrotasks();
assert.equal(h.pending.length, 1);
assert.equal(
reported,
false,
'the observer cannot report before owner settlement',
);
assert.equal(h.pending[0].options.signal.aborted, false);
assert.equal(h.pending[0].options.isCurrent(), true);
if (outcome === 'clear') engine.clear();
else
h.pending[0].finish(
outcome,
outcome === 'failed' ? new Error('owner install failed') : undefined,
);
const results = await Promise.all([first, second]);
if (outcome === 'failed' || outcome === 'clear') {
for (const result of results) {
assert.equal(result.drawn, 0);
assert.deepEqual(result.ids, []);
}
assert.equal(engine.count(), 0);
} else {
assert.equal(results[0].drawn, 1);
assert.equal(results[1].drawn, 1);
assert.deepEqual(results[1].ids, results[0].ids);
assert.equal(results[1].results[0].duplicate, true);
if (outcome === 'timeout') {
for (const result of results)
assert.equal(result.results[0].outlineUnavailable, true);
assert.equal(engine.list()[0].ring, null);
}
}
engine.clear();
}
});
test('a duplicate joins the physical deferred install but not another outline lookup', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
let lookups = 0;
const engine = createAnnotationEngine({
viewer: {},
renderer: h.renderer,
resolveTarget: async () => ({
...FIXED_POINT,
resolveOutline: async () => {
lookups++;
return { ...BUILDING_FP };
},
}),
});
const spec = { type: 'area', target: 'Capitol', label: 'Same' };
const initial = await engine.annotate([spec]);
await flushMicrotasks();
assert.equal(initial.drawn, 1, 'the progressive point already exists');
assert.equal(h.pending.length, 1);
let reported = false;
const duplicate = engine.annotate([spec]).then((result) => {
reported = true;
return result;
});
await flushMicrotasks();
assert.equal(reported, false);
assert.equal(h.pending.length, 1);
assert.equal(lookups, 1);
h.pending[0].finish('failed', new Error('deferred owner install failed'));
const result = await duplicate;
assert.equal(result.drawn, 1, 'the owner restored an honest point');
assert.deepEqual(result.ids, initial.ids);
assert.equal(result.results[0].outlineUnavailable, true);
assert.equal(engine.list()[0].ring, null);
assert.equal(lookups, 1);
assert.equal(h.pending.length, 1);
engine.clear();
});
test('duplicate-replacement: a renderer throw leaves no phantom mark and no leaked hold', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
@@ -1122,3 +1216,23 @@ test('only an explicit navigation request permits resolving distant annotation t
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
engine.destroy();
});
test('recaption inherits outline credit from borrowed geometry rather than its new anchor', async (t) => {
installAnimationFrameStubs(t);
const { renderer } = fakeRenderer();
let first = true;
const ring = [[-99,31],[-98.99,31],[-98.99,31.01],[-99,31]];
const engine = createAnnotationEngine({ viewer: {}, renderer,
resolveTarget: async () => {
if (first) { first = false; return { ...FIXED_POINT, ring, footprintKind: 'area', outlineCredit: 'tiles' }; }
return { ...FIXED_POINT, source: 'osm-local', resolveOutline: async () => null };
},
});
await engine.annotate([{ type: 'area', target: 'Library', label: 'Old' }]);
await engine.annotate([{ type: 'area', target: 'Library', label: 'New' }]);
await flushMicrotasks();
assert.equal(engine.list()[0].outlineCredit, 'tiles');
assert.equal(engine.list()[0].ring, ring);
engine.clear();
});
+75 -54
View File
@@ -1,3 +1,4 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
@@ -133,69 +134,89 @@ export function createHybridAnnotationRenderer(viewer) {
}
function finishBuilding(anno, entry, completion, options) {
const workTrace = options.workTrace;
const proxy = entry.worldProxy;
return completion
.then((result) => {
if (
routed.get(anno.id) !== entry ||
entry.worldProxy !== proxy ||
options.signal?.aborted ||
!(options.isCurrent?.() ?? true)
) {
world.remove(proxy);
if (routed.get(anno.id) === entry) remove(anno);
return { ...result, status: 'cancelled' };
}
if (result.status !== 'installed') {
world.remove(proxy);
entry.worldProxy = null;
return result;
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
return { ...result, status: 'timeout' };
}
const at = performance.now();
const workStart = workTrace ? performance.now() : 0;
try {
updateCaption(anno, entry);
} finally {
const ms = performance.now() - at;
result.timing.workMs += ms;
result.timing.lastSliceMs += ms;
result.timing.maxSliceMs = Math.max(
result.timing.maxSliceMs,
result.timing.lastSliceMs,
);
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (options.pendingVisible === false) {
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = null;
} else {
const pending = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
screen.update(pending);
entry.screenProxy = pending;
if (
routed.get(anno.id) !== entry ||
entry.worldProxy !== proxy ||
options.signal?.aborted ||
!(options.isCurrent?.() ?? true)
) {
world.remove(proxy);
if (routed.get(anno.id) === entry) remove(anno);
if (workTrace) workTrace.rendererStatus = 'cancelled';
return { ...result, status: 'cancelled' };
}
return { ...result, status: 'timeout' };
if (result.status !== 'installed') {
world.remove(proxy);
entry.worldProxy = null;
if (workTrace) workTrace.rendererStatus = result.status;
return result;
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
}
const at = performance.now();
try {
updateCaption(anno, entry);
} finally {
const ms = performance.now() - at;
result.timing.workMs += ms;
result.timing.lastSliceMs += ms;
result.timing.maxSliceMs = Math.max(
result.timing.maxSliceMs,
result.timing.lastSliceMs,
);
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (options.pendingVisible === false) {
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = null;
} else {
const pending = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
screen.update(pending);
entry.screenProxy = pending;
}
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
}
if (workTrace) workTrace.rendererStatus = result.status;
return result;
} finally {
if (workTrace)
recordWorkSlice(workTrace, 'hybrid-complete', workStart);
}
return result;
})
.catch((error) => {
// A later caption throw must roll back the already installed world parts too.
if (routed.get(anno.id) === entry) {
try {
remove(anno);
} catch {
/* original error wins; failed routes stay addressable */
const workStart = workTrace ? performance.now() : 0;
try {
// A later caption throw must roll back the already installed world parts too.
if (routed.get(anno.id) === entry) {
try {
remove(anno);
} catch {
/* original error wins; failed routes stay addressable */
}
} else world.remove(proxy);
throw error;
} finally {
if (workTrace) {
workTrace.rendererStatus = 'failed';
recordWorkSlice(workTrace, 'hybrid-rollback', workStart);
}
} else world.remove(proxy);
throw error;
}
});
}
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
class FakeClassList {
constructor(owner) {
@@ -213,6 +214,7 @@ function installBrowserGlobals(t) {
/** A viewer whose scene/camera is just enough for both sub-renderers. */
function fakeViewer(lon, lat) {
const credits = new Set();
const camera = cameraLookingAt(lon, lat);
const dataSources = [];
const frames = controlledFrames();
@@ -229,6 +231,11 @@ function fakeViewer(lon, lat) {
dataSources,
frames,
viewer: {
credits,
creditDisplay: {
addStaticCredit: (credit) => credits.add(credit),
removeStaticCredit: (credit) => credits.delete(credit),
},
scene,
camera,
trackedEntity: null,
@@ -269,7 +276,7 @@ function buildingAnnotation(id, offset = 0) {
return {
id, type: 'area', color: 'primary', label: null, alpha: 1,
anchor: { ...naiveRingCentroid(ring), height: 100 },
ring, footprintKind: 'building', buildingHeight: 20,
ring, footprintKind: 'building', buildingHeight: 20, outlineCredit: 'tiles',
};
}
@@ -591,6 +598,10 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
renderer.destroy();
});
function workTrace() {
return { timeOrigin: performance.timeOrigin, slices: [], dropped: 0 };
}
test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
@@ -598,6 +609,9 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
t.after(() => renderer.destroy());
const first = buildingAnnotation('sampled-building');
const second = buildingAnnotation('fallback-building', 0.01);
second.outlineCredit = 'osm';
const otherOwner = {};
showOsmCredit(viewer, otherOwner);
const probes = [];
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
viewer.scene.clampToHeight = (position) => {
@@ -611,10 +625,23 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
};
// Suppress screen points: these counts concern the eight outside-building
// probes, not the screen renderer's separate anchor-height sampling.
const firstCompletion = renderer.add(first, { pendingVisible: false });
const secondCompletion = renderer.add(second, { pendingVisible: false });
const firstTrace = workTrace();
const secondTrace = workTrace();
let diagnosticWriteFaults = 0;
Object.defineProperty(secondTrace, 'groundDetailDropped', {
get: () => 0,
set() {
diagnosticWriteFaults += 1;
throw new Error('diagnostic sink unavailable');
},
enumerable: true,
});
const firstCompletion = renderer.add(first, { pendingVisible: false, workTrace: firstTrace });
const secondCompletion = renderer.add(second, { pendingVisible: false, workTrace: secondTrace });
assert.equal(probes.length, 0, 'enqueue must not probe synchronously');
assert.equal(dataSources[0].entities.values.length, 0);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
'pending outlines add no tile credit to the other owner');
for (let frame = 0; frame < 16; frame += 1) {
const before = probes.length;
frames.emit();
@@ -624,8 +651,41 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
const [installed, fallback] = await Promise.all([firstCompletion, secondCompletion]);
assert.equal(installed.status, 'installed');
assert.equal(fallback.status, 'installed');
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 2);
assert.equal(installed.timing.probes, 8);
assert.equal(fallback.timing.probes, 8);
assert.ok(diagnosticWriteFaults > 0, 'the diagnostic failure was exercised');
for (const trace of [firstTrace, secondTrace]) {
assert.equal(trace.probes, 8);
assert.equal(trace.samplerStatus, 'installed');
assert.equal(trace.rendererStatus, 'installed');
assert.equal(trace.slices.filter(s => s.phase === 'ground-step').length, 8);
assert.equal(trace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(trace.dropped, 0);
assert.ok(trace.slices.every(s => Number.isFinite(s.start) && Number.isFinite(s.end) && s.end >= s.start));
assert.equal(trace.timeOrigin, performance.timeOrigin);
const parents = trace.slices.filter(s => s.phase === 'ground-step');
for (const [index, parent] of parents.entries()) {
const detail = parent.detail;
assert.equal(detail.index, index);
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
assert.equal(detail.clampResult,
trace === firstTrace && index === 4 ? 'threw' : 'returned');
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
if (index === 7) {
assert.equal(detail.commitStatus, 'attempted');
assert.ok(Number.isFinite(detail.commitStart) && Number.isFinite(detail.commitEnd));
assert.ok(detail.clampEnd <= detail.commitStart && detail.commitStart <= detail.commitEnd && detail.commitEnd <= parent.end);
} else {
assert.equal(detail.commitStatus, 'not-run');
assert.equal(detail.commitStart, null);
assert.equal(detail.commitEnd, null);
}
}
}
assert.notEqual(firstTrace.slices, secondTrace.slices);
assert.ok(firstTrace.slices.filter(s => s.phase === 'ground-step').at(-1).end <= secondTrace.slices.find(s => s.phase === 'ground-step').start, 'each queued task records only its own sampling frames');
const centroid = naiveRingCentroid(first.ring);
const lonScale = 111320 * Math.cos(Cesium.Math.toRadians(centroid.lat));
const radius = Math.max(...first.ring.map(([lon, lat]) =>
@@ -647,6 +707,13 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
const afterCompletion = probes.length;
frames.emit();
assert.equal(probes.length, afterCompletion, 'completion releases the sampler');
renderer.remove(first);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
'removing the last tile proxy retains the OSM building and other owner');
renderer.remove(second);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1);
hideOsmCredit(viewer, otherOwner);
assert.equal(viewer.credits.size, 0);
});
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
@@ -658,17 +725,23 @@ test('building cancellation, queued deadline and destroy prevent saved callbacks
let probeCount = 0;
viewer.scene.clampToHeight = (position) => { probeCount += 1; return position; };
const cancelled = buildingAnnotation('cancelled-building');
const cancelledCompletion = renderer.add(cancelled, { pendingVisible: false });
const cancelledTrace = workTrace();
const cancelledCompletion = renderer.add(cancelled, { pendingVisible: false, workTrace: cancelledTrace });
const savedCancelled = frames.snapshot();
renderer.remove(cancelled);
assert.equal((await cancelledCompletion).status, 'cancelled');
assert.equal(cancelledTrace.samplerStatus, 'cancelled');
assert.equal(cancelledTrace.rendererStatus, 'cancelled');
assert.equal(cancelledTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
for (const listener of savedCancelled) listener();
assert.equal(probeCount, 0);
assert.equal(viewer.credits.size, 0);
const head = buildingAnnotation('deadline-head');
const tail = buildingAnnotation('deadline-tail', 0.01);
const headCompletion = renderer.add(head, { pendingVisible: false });
const tailCompletion = renderer.add(tail, { pendingVisible: false });
const tailTrace = workTrace();
const tailCompletion = renderer.add(tail, { pendingVisible: false, workTrace: tailTrace });
frames.emit();
assert.equal(probeCount, 1);
const savedTimeout = frames.snapshot();
@@ -679,6 +752,11 @@ test('building cancellation, queued deadline and destroy prevent saved callbacks
const tailResult = await tailCompletion;
assert.equal(tailResult.status, 'timeout');
assert.equal(tailResult.timing.probes, 0);
assert.equal(tailTrace.samplerStatus, 'timeout');
assert.equal(tailTrace.rendererStatus, 'timeout');
assert.equal(tailTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
assert.equal(tailTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(viewer.credits.size, 0);
for (const listener of savedTimeout) listener();
assert.equal(probeCount, 1, 'saved callbacks cannot revive timed-out tasks');
assert.equal(dataSources[0].entities.values.length, 0);
@@ -693,6 +771,7 @@ test('building cancellation, queued deadline and destroy prevent saved callbacks
assert.equal(probeCount, 1);
assert.equal(dataSources.length, 0, 'destroy removes the owned data source');
assert.equal(frames.size, 0, 'destroy removes frame listeners');
assert.equal(viewer.credits.size, 0);
});
test('a timed-out building upgrade keeps its existing pending screen group', async (t) => {
@@ -729,11 +808,13 @@ test('an asynchronous caption-conversion throw rolls back multipart world geomet
const hole = anno.ring.map(([lon, lat]) => [lon + 0.0002, lat + 0.0002]);
const island = anno.ring.map(([lon, lat]) => [lon + 0.002, lat]);
anno.polygons = [[anno.ring, hole], [island]];
const completion = renderer.update(anno);
const captionTrace = workTrace();
const completion = renderer.update(anno, { workTrace: captionTrace });
// Frame projection is still ordinary pending-point work. Arm the failure
// only after sampling completes, before its promise converts the caption.
for (let frame = 0; frame < 8; frame += 1) frames.emit();
assert.ok(dataSources[0].entities.values.length > 0, 'multipart world geometry landed before caption conversion');
assert.equal(viewer.credits.size, 2, 'credit belongs to the committed world proxy');
const originalSetAttribute = dot.setAttribute.bind(dot);
const failure = new Error('caption conversion failed');
dot.setAttribute = (name, value) => {
@@ -746,7 +827,12 @@ test('an asynchronous caption-conversion throw rolls back multipart world geomet
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
const rejected = assert.rejects(completion, (error) => error === failure);
await rejected;
assert.equal(captionTrace.rendererStatus, 'failed');
assert.equal(captionTrace.slices.filter(s => s.phase === 'ground-step').length, 8);
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-rollback').length, 1);
assert.equal(dataSources[0].entities.values.length, 0, 'all classification, cage and outline parts roll back');
assert.equal(viewer.credits.size, 0, 'caption rollback releases that same proxy');
assert.equal(annotationGroups(svg).length, 1, 'failed screen cleanup remains addressable');
globalThis.window.setTimeout = workingSetTimeout;
dot.setAttribute = originalSetAttribute;
+7 -5
View File
@@ -159,7 +159,7 @@ export function createOutlineRungs({
return street ? streetFootprint(street) : null;
}
async function buildingRung(ctx) {
async function buildingRung(ctx, requireContainment = false) {
const decoded = await tilesAround(
ctx.lat,
ctx.lon,
@@ -167,7 +167,8 @@ export function createOutlineRungs({
ctx.signal,
);
const building = decoded && buildingFromTiles(decoded, ctx);
if (!building) return null;
if (!building || (requireContainment && building.contains !== true))
return null;
return {
ring: building.ring,
polygons: building.polygons,
@@ -255,7 +256,8 @@ export function createOutlineRungs({
if (!fp) return null;
const patch = finish(fp, asScope);
if (patch?.ring) {
ctx.credit?.(provider === 'openfreemap' ? 'tiles' : 'osm');
patch.outlineCredit = provider === 'openfreemap' ? 'tiles' : 'osm';
ctx.credit?.(patch.outlineCredit);
// One line per answered outline, like the resolver's own trace.
console.log(
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${provider}` +
@@ -316,11 +318,11 @@ export function createOutlineRungs({
if (grounds) return grounds;
}
// A named building nobody outlined: the footprint at its point. Never for
// A named building nobody outlined: the footprint holding its point. Never for
// a grounds ask, where the building is the wrong shape.
if (scope === 'building' && named && !ctx.around && !ctx.groundsLike) {
const building = accept(
await buildingRung(ctx),
await buildingRung(ctx, true),
'building',
'openfreemap',
);
+67 -2
View File
@@ -148,6 +148,7 @@ test('a street comes from tiles before the configured ladder, as merged ribbons'
assert.equal(run.calls.base, 0, 'the ladder is not consulted');
assert.deepEqual(run.calls.finished, ['street']);
assert.deepEqual(credits, ['tiles']);
assert.equal(result.outlineCredit, 'tiles');
assert.equal(tiles.asked[0].zoom, 14);
assert.equal(nominatim.asks.length, 0, 'streets never reach Nominatim');
});
@@ -180,6 +181,7 @@ test('"circle this building" picks the tile building at the point first', async
run,
);
assert.equal(result.footprintKind, 'building');
assert.equal(result.outlineCredit, 'tiles');
assert.equal(run.calls.base, 0);
assert.equal(nominatim.asks.length, 0);
const primary = [square(CAPITOL.lat, CAPITOL.lon, 0.0002)];
@@ -208,8 +210,8 @@ test('"circle this building" picks the tile building at the point first', async
);
assert.deepEqual(
multipart.polygons,
[primary, other],
'the building rung retains all feature parts',
[primary],
'the building rung retains the selected component, not unrelated siblings',
);
});
@@ -229,6 +231,64 @@ test('a named building asks Nominatim, then falls back to the tile building', as
assert.equal(result.footprintKind, 'building');
});
test('named building tile fallbacks require the anchor in filled geometry', async () => {
const nearby = {
anchor: { lat: 0, lon: 0.00035 },
rings: [square(0, 0, 0.0001)],
};
for (const { anchor, rings } of [
nearby,
{
anchor: { lat: 0, lon: 0 },
rings: [square(0, 0, 0.001), square(0, 0, 0.0002)],
},
]) {
const credits = [];
const run = ladder(UNAVAILABLE);
const result = await createOutlineRungs({
tiles: {
fetchBounds: async () => ({
tiles: [{ buildings: [{ rings, heightM: null }] }],
}),
},
nominatim: fakeNominatim(null),
}).resolve(
ctx({
...anchor,
view: anchor,
scope: 'building',
target: 'Gateville',
matchName: 'Gateville',
credit: (kind) => credits.push(kind),
}),
run,
);
assert.equal(result, UNAVAILABLE);
assert.deepEqual(run.calls.finished, []);
assert.deepEqual(credits, []);
}
const pointed = await createOutlineRungs({
tiles: {
fetchBounds: async () => ({
tiles: [{ buildings: [{ rings: nearby.rings, heightM: null }] }],
}),
},
nominatim: fakeNominatim(null),
}).resolve(
ctx({
...nearby.anchor,
view: nearby.anchor,
scope: 'building',
target: 'this building',
fromName: false,
}),
ladder(UNAVAILABLE),
);
assert.equal(pointed.footprintKind, 'building');
assert.equal(pointed.outlineCredit, 'tiles');
});
test('a city with no configured Overpass is outlined by Nominatim', async () => {
const ring = square(43.6, 1.44, 0.05);
const nominatim = fakeNominatim({ polygons: [[ring]], class: 'boundary' });
@@ -249,6 +309,7 @@ test('a city with no configured Overpass is outlined by Nominatim', async () =>
run,
);
assert.deepEqual(result.ring, ring);
assert.equal(result.outlineCredit, 'osm');
assert.deepEqual(nominatim.asks[0], {
query: 'Toulouse',
kind: 'city',
@@ -721,6 +782,10 @@ test('default settings: outline, street and building asks send nothing to Overpa
target: 'this building',
});
assert.equal(building.outline?.footprintKind, 'building');
assert.equal(building.outline.outlineCredit, 'tiles');
assert.equal(street.outline.outlineCredit, 'tiles');
assert.equal(paris.outline.outlineCredit, 'osm');
assert.equal(park.outline.outlineCredit, 'osm');
assert.ok(
upstream.every((url) =>
+118
View File
@@ -64,6 +64,11 @@ function assertBundledArea(resolved, label) {
assert.equal(resolved.label, label);
assert.equal(resolved.footprintKind, 'area');
assert.equal(resolved.synthesized, false);
assert.equal(
resolved.outlineCredit || null,
null,
'bundled data is not an OSM outline',
);
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
assert.ok(resolved.polygons.length >= 1);
@@ -333,6 +338,77 @@ test('a courtyard outline with no filled candidate anchor remains a point', asyn
}
});
test('local dateline outlines share a frame for area, centering and filled anchors', async () => {
const ring = [
[179.9995, -0.0005],
[-179.9995, -0.0005],
[-179.9995, 0.0005],
[179.9995, 0.0005],
[179.9995, -0.0005],
];
const hole = [
[179.9998, -0.0003],
[-179.9998, -0.0003],
[-179.9998, 0.0003],
[179.9998, 0.0003],
[179.9998, -0.0003],
];
const polygons = [[ring, hole]];
const { resolve } = harness(null, {
outlineRungs: rungsAnswering({
ring,
polygons,
kind: 'building',
heightM: 20,
}),
});
for (const deferFootprint of [true, false]) {
for (const [lat, lon, filled] of [
[0.0004, 179.9996, true],
[0, 179.99999, false],
[0.0004, 0, false],
]) {
const result = await resolve('this building', viewerAt(lat, lon, 1000), {
latitude: lat,
longitude: lon,
entityKind: 'building',
deferFootprint,
});
const outline = deferFootprint ? await result.resolveOutline() : result;
if (!filled) {
assert.equal(outline?.ring ?? null, null);
continue;
}
assert.equal(outline.ring, ring);
assert.equal(outline.polygons, polygons);
assert.deepEqual(
[outline.lat, outline.lon],
[lat, lon],
'keep the filled shell anchor',
);
}
}
const plain = harness(null, {
outlineRungs: rungsAnswering({ ring, kind: 'building', heightM: 20 }),
});
const pending = await plain.resolve(
'this building',
viewerAt(0, 179.9999, 1000),
{
latitude: 0,
longitude: 179.9999,
entityKind: 'building',
},
);
const centered = await pending.resolveOutline();
assert.equal(centered.ring, ring);
assert.ok(
Math.abs(centered.lon) > 179.999 &&
centered.lon >= -180 &&
centered.lon < 180,
);
});
test('a coarse bundled admin-1 city waits for a finer outline', async () => {
const overParis = viewerAt(48.8566, 2.3522, 40_000);
// The outer-ring vertex mean is in the hole; the resolved anchor is in
@@ -436,6 +512,11 @@ test('a campus ask reaches the campus, not the Census place of its town', async
assert.notEqual(resolved?.source, 'bundled', ask);
const outline = await resolved.resolveOutline();
assert.deepEqual(outline.ring, campus, ask);
assert.equal(
outline.outlineCredit,
null,
'an injected outline is not inferred OSM',
);
assert.equal(
rungs.asked[0].scope,
'compound',
@@ -518,3 +599,40 @@ test('a grounds ask never takes the state the geocoder typed', async () => {
assert.equal(rungs.asked[0].scope, 'compound', ask);
}
});
test('configured OSM geometry retains its origin through inline, deferred and cached base results', async () => {
const ring = square(31, -99, 0.001);
let calls = 0;
const resolver = createAnnotationResolver({
boundarySource: {
query: async () => {
calls++;
return [
{
type: 'way',
id: 1,
tags: { name: 'Library', building: 'yes' },
geometry: ring.map(([lon, lat]) => ({ lon, lat })),
},
];
},
},
outlineRungs: { resolve: (_ctx, { base }) => base() },
});
for (const deferFootprint of [false, true]) {
const result = await resolver.resolveAnnotationTarget({
viewer: viewerAt(31, -99, 3000),
latitude: 31,
longitude: -99,
target: 'Library',
entityKind: 'building',
footprint: true,
deferFootprint,
});
const outline = deferFootprint ? await result.resolveOutline() : result;
assert.equal(outline.footprintKind, 'building');
assert.equal(outline.outlineCredit, 'osm');
assert.deepEqual(outline.ring, ring);
}
assert.equal(calls, 1, 'cached geometry retains its original provider');
});
+80 -9
View File
@@ -45,6 +45,37 @@ import { createOutlineRungs } from './outlineRungs.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Prepare only local dateline outlines for planar admission and centering. */
function localOutlineFrame(fp) {
const original = { ring: fp?.ring, polygons: fp?.polygons, reference: null };
if (!Array.isArray(fp?.ring) || fp.ring.length < 3) return original;
let west = Infinity;
let east = -Infinity;
for (const [lon] of fp.ring) {
west = Math.min(west, lon);
east = Math.max(east, lon);
}
if (east - west <= 180) return original;
const reference = fp.ring[0][0];
west = Infinity;
east = -Infinity;
const shift = (ring) =>
ring.map(([lon, lat]) => {
const x = lon - 360 * Math.round((lon - reference) / 360);
west = Math.min(west, x);
east = Math.max(east, x);
return [x, lat];
});
const ring = shift(fp.ring);
const polygons = Array.isArray(fp.polygons)
? fp.polygons.map((part) => part.map(shift))
: fp.polygons;
// Shapes still spanning half the globe retain their existing behavior. Source coordinates
// are never rewritten, including holes and unrelated multipart geometry.
if (!Number.isFinite(east - west) || east - west >= 180) return original;
return { ring, polygons, reference };
}
/** Ms the analyst region lookup waits on geocode + admin boundary. */
const REGION_FALLBACK_BUDGET_MS = 3_000;
@@ -83,6 +114,8 @@ export function createAnnotationResolver({
outlineRungs = createOutlineRungs(),
} = {}) {
requireFeatureSource(featureSource);
const featureOutlineCredit =
featureSource.outlineCredit === 'osm' ? 'osm' : null;
lifetime?.throwIfAborted();
/**
@@ -768,6 +801,7 @@ export function createAnnotationResolver({
/** Validate a footprint for its scope and re-centre it (resolveOutline's patch). */
const finishOutline = (fp, scope) => {
const frame = localOutlineFrame(fp);
// Scope sanity: reject a real footprint whose area is wildly wrong for the asked
// scope (a building/compound/neighborhood intent must never draw a state-sized
// blob). Synthesized discs are deliberately sized and exempt.
@@ -776,25 +810,34 @@ export function createAnnotationResolver({
!fp.synthesized &&
Array.isArray(fp.ring) &&
fp.ring.length >= 3 &&
exceedsScopeArea(fp, scope)
exceedsScopeArea(frame, scope)
) {
fp = null;
}
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
let centroid = ringCentroid(fp.ring);
let centroid = ringCentroid(frame.ring);
if (!centroid) return null;
// An outer-ring mean can lie in a hole or outside a concave part. Keep
// a filled original anchor or decline, without changing ordinary centering
// or inventing a new interior point.
if (
Array.isArray(fp.polygons) &&
!polygonsContainPoint(fp.polygons, centroid.lat, centroid.lon)
Array.isArray(frame.polygons) &&
!polygonsContainPoint(frame.polygons, centroid.lat, centroid.lon)
) {
if (!polygonsContainPoint(fp.polygons, lat, lon)) return null;
const anchorLon =
frame.reference === null
? lon
: lon - 360 * Math.round((lon - frame.reference) / 360);
if (!polygonsContainPoint(frame.polygons, lat, anchorLon)) return null;
centroid = { lat, lon };
}
if (
frame.reference !== null &&
(centroid.lon < -180 || centroid.lon >= 180)
)
centroid.lon = ((((centroid.lon + 180) % 360) + 360) % 360) - 180;
// Same drift bound as the anchor gate: a polygon whose centroid would drag a good
// anchor out of plausibility is the wrong feature — drop it, keep the point.
if (
@@ -808,6 +851,7 @@ export function createAnnotationResolver({
footprintKind: fp.kind,
buildingHeight: fp.heightM,
synthesized: Boolean(fp.synthesized),
outlineCredit: fp.outlineCredit || null,
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
lat: centroid.lat,
lon: centroid.lon,
@@ -857,6 +901,7 @@ export function createAnnotationResolver({
let outlineUnavailable = false;
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
let polygons = null; // every part of a multi-part outline, when known
let outlineCredit = null;
if (footprint && !deferFootprint) {
const fp = await resolveOutline();
outlineUnavailable = isUnavailableCapability(fp);
@@ -866,6 +911,7 @@ export function createAnnotationResolver({
buildingHeight = fp.buildingHeight;
synthesized = fp.synthesized;
polygons = fp.polygons || null;
outlineCredit = fp.outlineCredit || null;
// Re-center the anchor on the resolved footprint centroid.
lat = fp.lat;
lon = fp.lon;
@@ -893,6 +939,7 @@ export function createAnnotationResolver({
source,
synthesized,
outlineUnavailable,
outlineCredit,
...(polygons ? { polygons } : {}),
viewport: placeViewport,
...(footprint && deferFootprint ? { resolveOutline } : {}),
@@ -1517,7 +1564,12 @@ export function createAnnotationResolver({
? 40
: 120;
ring = simplifyRing(ring, tolM);
const fp = { ring, kind: 'area', heightM: null };
const fp = {
ring,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
// Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San
// Francisco") can match the CITY admin. Caching an oversized ring would poison the
@@ -1601,7 +1653,14 @@ export function createAnnotationResolver({
bestRing = closeRing(coords.map((p) => [p.lon, p.lat]));
}
}
return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null;
return bestRing
? {
ring: bestRing,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
}
: null;
}
/**
@@ -1644,7 +1703,12 @@ export function createAnnotationResolver({
}
}
if (bestRing) {
const fp = { ring: bestRing, kind: 'area', heightM: null };
const fp = {
ring: bestRing,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
cacheWrite(footprintCache, cacheKey, fp);
return fp;
}
@@ -1675,6 +1739,7 @@ export function createAnnotationResolver({
ring: bufferCorridor(line, 11),
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
cacheWrite(footprintCache, cacheKey, fp);
return fp;
@@ -1813,7 +1878,12 @@ export function createAnnotationResolver({
areaM2 < bestArea * 0.999 ||
(areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins
if (better) {
best = { ring: ringLonLat, kind: 'area', heightM: null };
best = {
ring: ringLonLat,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
bestArea = areaM2;
bestNameMatch = nameMatch;
}
@@ -2075,6 +2145,7 @@ export function createAnnotationResolver({
best = {
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
kind: isBuilding ? 'building' : 'area',
outlineCredit: featureOutlineCredit,
heightM: isBuilding
? (element.heightM ??
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
@@ -653,6 +653,7 @@ export function createScreenAnnotationRenderer(
}
}
if (placed.length > 1) decollideCallouts(placed);
placeCalloutsAboveVoicePreview(placed, svg, w, h);
// 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
@@ -855,6 +856,70 @@ function decollideCallouts(callouts) {
}
}
/** Keep an overlapping callout above the actual open Voice Preview, when it fits. */
function placeCalloutsAboveVoicePreview(callouts, svg, width, height) {
if (!callouts.length) return;
const panel = document.getElementById('gev-voice-panel');
if (
!panel ||
panel.hidden ||
panel.dataset.view !== 'preview' ||
panel.checkVisibility?.({
opacityProperty: true,
visibilityProperty: true,
}) === false
)
return;
const rect = panel.getBoundingClientRect(),
bounds = svg.getBoundingClientRect();
if (
rect.width <= 0 ||
rect.height <= 0 ||
bounds.width <= 0 ||
bounds.height <= 0
)
return;
// The overlay uses preserveAspectRatio="none": each axis scales independently.
const sx = width / bounds.width,
sy = height / bounds.height;
const blocked = {
_x: (rect.left - bounds.left) * sx,
_y: (rect.top - bounds.top) * sy,
width: rect.width * sx,
height: rect.height * sy,
};
const intersects = (a, b) =>
a._x < b._x + b.width &&
a._x + a.width > b._x &&
a._y < b._y + b.height &&
a._y + a.height > b._y;
for (const callout of callouts) {
if (!intersects(callout, blocked) || callout.width > width - 16) continue;
const candidate = {
_x: Math.max(8, Math.min(callout._x, width - callout.width - 8)),
_y: blocked._y - callout.height - 8,
width: callout.width,
height: callout.height,
};
if (
candidate._y < 8 ||
candidate._y + candidate.height > height - 8 ||
callouts.some(
(other) => other !== callout && intersects(candidate, other),
)
)
continue;
positionCallout(callout, candidate._x, candidate._y, false);
if (callout._leader) {
callout._leader.setAttribute('x2', candidate._x.toFixed(1));
callout._leader.setAttribute(
'y2',
(candidate._y + candidate.height).toFixed(1),
);
}
}
}
function ensureArrowMarker(defs, key, c) {
if (defs.querySelector(`#gev-arrow-${key}`)) return;
const marker = document.createElementNS(SVGNS, 'marker');
@@ -104,6 +104,10 @@ class FakeElement {
return { x: 0, y: 0, width: 40, height: 16 };
}
getBoundingClientRect() {
return this._rect ?? { left: 0, top: 0, width: 0, height: 0 };
}
set innerHTML(value) {
this._innerHTML = value;
}
@@ -201,6 +205,86 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
renderer.destroy();
});
test('Preview avoids the map callout without moving its world anchor', (t) => {
const originals = {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
project: Cesium.SceneTransforms.worldToWindowCoordinates,
};
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return { x: 400, y: lon > 0.0005 ? 166 : 200 };
};
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originals.project;
for (const key of ['document', 'requestAnimationFrame']) {
if (originals[key] === undefined) delete globalThis[key];
else globalThis[key] = originals[key];
}
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 800);
const camera = {
positionWC,
directionWC: Cesium.Cartesian3.normalize(Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()), new Cesium.Cartesian3()),
positionCartographic: { height: 800 },
};
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
};
const panel = new FakeElement('div');
panel.id = 'gev-voice-panel';
panel.dataset = { view: 'preview' };
panel.hidden = true;
panel.checkVisibility = () => true;
// Non-unit, independent axis scales and a nonzero client offset.
// The actual overlay uses preserveAspectRatio="none", not letterboxing.
panel._rect = { left: 210, top: 120, width: 100, height: 120 };
document.body.appendChild(panel);
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
t.after(() => renderer.destroy());
const anno = { id: 'preview-label', type: 'label', color: 'primary', label: 'Texas', alpha: 1, anchor: { lon: 0, lat: 0, height: 0 } };
renderer.add(anno);
const { svg, group } = findAnnotationGroup(document);
svg._rect = { left: 10, top: 20, width: 640, height: 400 };
const callout = group.querySelector('.gev-anno-callout');
const leader = group.querySelector('.gev-anno-leader');
const dot = group.querySelector('.gev-anno-dot');
const natural = callout.getAttribute('transform');
const anchor = structuredClone(anno.anchor);
panel.hidden = false;
renderer.sync();
assert.equal(callout.getAttribute('transform'), 'translate(418.0, 146.0)');
assert.equal(leader.getAttribute('x2'), '418.0');
assert.equal(leader.getAttribute('y2'), '172.0');
assert.equal(leader.getAttribute('x1'), '400.0');
assert.equal(leader.getAttribute('y1'), '200.0');
assert.equal(dot.getAttribute('cx'), '400.0');
assert.equal(dot.getAttribute('cy'), '200.0');
assert.deepEqual(anno.anchor, anchor);
assert.equal(findAnnotationGroup(document).group, group);
panel.hidden = true;
renderer.sync();
assert.equal(callout.getAttribute('transform'), natural, 'closing Preview restores natural placement');
for (const variant of ['settings', 'invisible', 'no-overlap', 'no-room', 'collision']) {
panel.hidden = false;
panel.dataset.view = variant === 'settings' ? 'settings' : 'preview';
panel.checkVisibility = () => variant !== 'invisible';
panel._rect = variant === 'no-overlap'
? { left: 510, top: 120, width: 100, height: 120 }
: variant === 'no-room'
? { left: 210, top: 25, width: 100, height: 180 }
: { left: 210, top: 120, width: 100, height: 120 };
if (variant === 'collision') renderer.add({ ...anno, id: 'above-slot', anchor: { lon: 0.001, lat: 0, height: 0 } });
renderer.sync();
assert.equal(callout.getAttribute('transform'), natural, variant);
}
});
test("annotation fade consumes the actual tracked host paint rectangle after layout", (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
+136 -46
View File
@@ -1,3 +1,4 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import * as Cesium from 'cesium';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
@@ -60,6 +61,17 @@ export function createWorldAnnotationRenderer(viewer) {
pendingGround.get(anno)?.finish('cancelled');
return new Promise((resolve, reject) => {
const started = performance.now();
const workTrace = options.workTrace;
if (workTrace) workTrace.samplerStart = started;
let groundDetailSlots = 0;
let groundDetailReady = true;
if (workTrace) {
try {
workTrace.groundDetailDropped = 0;
} catch {
groundDetailReady = false;
}
}
const task = {
anno,
points: groundProbePositions(anno.ring),
@@ -89,67 +101,132 @@ export function createWorldAnnotationRenderer(viewer) {
observingGround = false;
}
timing.elapsedMs = performance.now() - started;
if (workTrace) {
workTrace.probes = timing.probes;
workTrace.samplerStatus = status;
}
if (error) reject(error);
else resolve({ status, timing });
};
task.abort = () => task.finish('cancelled');
task.step = () => {
if (!task.current()) return task.finish('cancelled');
if (performance.now() - started >= 2000) return task.finish('timeout');
const sliceStart = performance.now();
let error;
try {
if (
viewer.scene.clampToHeightSupported &&
typeof viewer.scene.clampToHeight === 'function'
) {
const [lon, lat] = task.points[task.index++];
timing.probes += 1;
try {
const c = viewer.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)
task.samples.push(h);
const workStart = workTrace ? performance.now() : 0;
// Nested call/commit wall-time only; the original parent remains the gate.
const detail =
workTrace && groundDetailSlots++ < 8
? {
index: task.index,
status: groundDetailReady ? 'recorded' : 'missing',
clampStart: null,
clampEnd: null,
clampResult: 'not-run',
commitStart: null,
commitEnd: null,
commitStatus: 'not-run',
}
: undefined;
const note = detail
? (key) => {
try {
const at = performance.now();
if (Number.isFinite(at)) detail[key] = at;
else detail.status = 'missing';
} catch {
detail.status = 'missing';
}
} catch {
/* surface not ready: preserve the existing sample filter */
}
} else task.index = task.points.length;
if (performance.now() - started >= 2000) {
task.finish('timeout');
return;
: null;
if (workTrace && !detail) {
try {
workTrace.groundDetailDropped += 1;
} catch {
// Missing attribution must not change the sampler outcome.
}
}
try {
if (!task.current()) return task.finish('cancelled');
if (task.index === task.points.length) {
task.samples.sort((a, b) => a - b);
const ground = task.samples.length
? task.samples[Math.floor(task.samples.length * 0.3)]
: null;
add(anno, { sampledGroundHeight: ground, isCurrent: task.current });
if (performance.now() >= timing.deadlineMs) {
if (performance.now() - started >= 2000)
return task.finish('timeout');
const sliceStart = performance.now();
let error;
try {
if (
viewer.scene.clampToHeightSupported &&
typeof viewer.scene.clampToHeight === 'function'
) {
const [lon, lat] = task.points[task.index++];
timing.probes += 1;
try {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
let c;
note?.('clampStart');
if (detail) detail.clampResult = 'threw';
try {
c = viewer.scene.clampToHeight(position);
if (detail) detail.clampResult = 'returned';
} finally {
note?.('clampEnd');
}
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000)
task.samples.push(h);
}
} catch {
/* surface not ready: preserve the existing sample filter */
}
} else task.index = task.points.length;
if (performance.now() - started >= 2000) {
task.finish('timeout');
remove(anno);
return;
}
if (!task.current()) return task.finish('cancelled');
if (task.index === task.points.length) {
if (detail) detail.commitStatus = 'attempted';
note?.('commitStart');
task.samples.sort((a, b) => a - b);
const ground = task.samples.length
? task.samples[Math.floor(task.samples.length * 0.3)]
: null;
add(anno, {
sampledGroundHeight: ground,
isCurrent: task.current,
});
if (performance.now() >= timing.deadlineMs) {
task.finish('timeout');
remove(anno);
return;
}
}
} catch (caught) {
error = caught;
} finally {
timing.lastSliceMs = performance.now() - sliceStart;
timing.workMs += timing.lastSliceMs;
timing.maxSliceMs = Math.max(timing.maxSliceMs, timing.lastSliceMs);
}
} catch (caught) {
error = caught;
if (error) {
task.finish('failed', error);
try {
remove(anno);
} catch {
/* retain the original commit error */
}
} else if (task.index === task.points.length)
task.finish('installed');
} finally {
timing.lastSliceMs = performance.now() - sliceStart;
timing.workMs += timing.lastSliceMs;
timing.maxSliceMs = Math.max(timing.maxSliceMs, timing.lastSliceMs);
// Commit includes sort/selection/add/deadline/finish or rollback,
// including intervening bookkeeping; it is not exclusive entity cost.
if (detail?.commitStatus === 'attempted') note('commitEnd');
if (workTrace)
recordWorkSlice(
workTrace,
'ground-step',
workStart,
undefined,
detail,
);
}
if (error) {
task.finish('failed', error);
try {
remove(anno);
} catch {
/* retain the original commit error */
}
} else if (task.index === task.points.length) task.finish('installed');
};
pendingGround.set(anno, task);
groundQueue.push(task);
@@ -465,6 +542,19 @@ export function createWorldAnnotationRenderer(viewer) {
}
entities.push(pointMarker(anno, base));
}
if (
entities.length &&
anno.ring?.length >= 3 &&
!anno.synthesized &&
(anno.outlineCredit === 'tiles' || anno.outlineCredit === 'osm') &&
(options.isCurrent?.() ?? true)
) {
// The actual world proxy owns both geometry and credit, including rollback.
osmAnnotations.add(anno);
showOsmCredit(viewer, anno, {
openMapTiles: anno.outlineCredit === 'tiles',
});
}
}
// Captions belong to the hybrid renderer's screen-space callouts.