mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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:
commit
578ebdc030
449 files changed
+30400
-3429
No files matched your search
@@ -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 —
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user