feat(outlines): restore voice outlines without public Overpass

use bundled place boundaries and shared OpenFreeMap tiles before a guarded Nominatim fallback

preserve multipart shapes, holes and cancellation,
and keep pending annotations visible while ground sampling finishes across frames

keep non-Latin name distinctions when selecting provider results,
and retain an outline anchor only when it lies in filled geometry
This commit is contained in:
manjunath committed 2026-10-27 13:24:02 +00:00
commit d4434aa6d7
1489 files changed
+196442 -12353

No files matched your search

+87
View File
@@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
assert.deepEqual(geocodes, []);
});
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
for (const types of [['locality', 'political'], ['natural_feature']]) {
const { resolve, geocodes } = harness({
lat: -28.0167,
lng: 153.4,
label: 'Gold Coast, Australia',
name: 'Gold Coast',
types,
});
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
assert.equal(resolved?.source, 'geocode');
assert.equal(resolved.lat, -28.0167);
assert.equal(resolved.lon, 153.4);
assert.equal(resolved.naturalRegion, undefined);
const outline = await resolved.resolveOutline();
assert.equal(outline?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Gold Coast']);
}
});
test('natural annotation outlines require a geocoded anchor', async () => {
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
const miss = harness();
assert.equal(await miss.resolve('the Alps', viewer), null);
assert.ok(miss.geocodes.includes('the Alps'));
}
const anchored = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types: ['natural_feature'],
});
const resolved = await anchored.resolve('the Alps', {});
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.naturalRegion, 'Alps');
assert.ok(outline.ring.length >= 8);
});
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
const { resolve, geocodes } = harness({
lat: 45.6336,
lng: -74.86493,
label: 'Canadian Shield',
name: 'Canadian Shield',
types: ['natural_feature'],
});
const resolved = await resolve(
'Canadian Shield',
viewerAt(45.6336, -74.86493),
);
assert.equal(resolved?.source, 'geocode');
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Canadian Shield']);
});
test('geocoded natural outlines respect district and around intent', async () => {
for (const extra of [
{ entityKind: 'district' },
{ intent: 'around_the_thing' },
]) {
const { resolve, geocodes } = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types:
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
});
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
assert.ok(geocodes.includes('the Alps'));
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
}
});
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
const controller = new AbortController();
const { resolve, geocodes } = harness();
const pending = resolve('the Alps', viewerAt(46.5, 10), {
signal: controller.signal,
});
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(geocodes, []);
});
test('country asks and geocoded country scopes use the bundled outline', async () => {
const direct = harness();
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
+444 -52
View File
@@ -1,6 +1,7 @@
import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from '../geoDistance.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -63,9 +64,9 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2;
* null — DEFINITIVE no-polygon (never retried; the honest point stands);
* undefined — TRANSIENT failure (re-run once per delaysMs entry, then give up);
* rate-limit object — OVERPASS THROTTLE (one retry, honoring Retry-After + ladder).
* `isStale()` is consulted after each backoff wait so a cleared/superseded board stops
* retrying immediately. A thrown resolver counts as definitive — the pre-retry
* catch→point behavior. Exported for tests.
* `signal` cancels backoff waits; `isStale()` guards each continuation so a
* cleared/superseded board stops retrying immediately. A thrown resolver counts as
* definitive — the pre-retry catch→point behavior. Exported for tests.
*/
export async function resolveOutlineWithRetry(
resolveOutline,
@@ -73,16 +74,19 @@ export async function resolveOutlineWithRetry(
delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false,
waitFn = wait,
signal,
} = {},
) {
let retriedRateLimit = false;
for (let attempt = 0; ; attempt += 1) {
if (signal?.aborted || isStale()) return undefined;
let fp;
try {
fp = await resolveOutline();
} catch {
fp = null; // hard failure — definitive, keep the honest point
}
if (signal?.aborted || isStale()) return undefined;
if (isUnavailableCapability(fp)) return fp;
if (isRateLimitedOutcome(fp)) {
// A throttle gets one deliberately spaced retry. If that retry is throttled
@@ -91,15 +95,15 @@ export async function resolveOutlineWithRetry(
const retryAfterMs = Number.isFinite(fp.retryAfterMs)
? fp.retryAfterMs
: 0;
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
if (isStale()) return undefined;
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]), signal);
if (signal?.aborted || isStale()) return undefined;
retriedRateLimit = true;
continue;
}
if (fp !== undefined) return fp;
if (attempt >= delaysMs.length) return undefined; // transient budget exhausted
await waitFn(delaysMs[attempt]);
if (isStale()) return undefined; // board cleared/superseded mid-backoff
await waitFn(delaysMs[attempt], signal);
if (signal?.aborted || isStale()) return undefined; // board cleared/superseded mid-backoff
}
}
@@ -207,12 +211,105 @@ export function createAnnotationEngine({
*/
function rollbackRendererState(anno) {
try {
renderer.remove(anno);
removeRendered(anno);
} catch {
/* the renderer is already in a bad way; the original error wins */
}
}
function removeRendered(anno) {
if (anno._pendingReplacement) {
const replacement = anno._pendingReplacement;
anno._pendingReplacement = null;
retainCleanup(anno, replacement);
}
let failure;
try {
cleanupRetired(anno);
} catch (error) {
failure = error;
}
try {
renderer.remove(anno);
} catch (error) {
failure ||= error;
}
if (failure) {
anno.cleanupPending = true;
throw failure;
}
delete anno.cleanupPending;
}
function retainCleanup(owner, route) {
const retired = (owner._retiredRoutes ||= new Set());
for (const previous of route._retiredRoutes || [])
if (previous !== owner) retired.add(previous);
delete route._retiredRoutes;
if (route._pendingReplacement && route._pendingReplacement !== owner)
retired.add(route._pendingReplacement);
route._pendingReplacement = null;
if (route !== owner) retired.add(route);
}
function cleanupRetired(anno) {
let failure;
for (const route of anno._retiredRoutes || []) {
try {
renderer.remove(route);
anno._retiredRoutes.delete(route);
} catch (error) {
failure ||= error;
}
}
if (failure) {
anno.cleanupPending = true;
throw failure;
}
delete anno.cleanupPending;
}
function cancelReplacement(anno) {
if (!anno._pendingReplacement) return;
const replacement = anno._pendingReplacement;
anno._pendingReplacement = null;
retainCleanup(anno, replacement);
try {
cleanupRetired(anno);
} catch {
/* cleanup-only ownership remains visible; active authority is revoked */
}
}
function installOptions(
anno,
controller,
myGen,
current = () => annotations.get(anno.id) === anno,
) {
return {
signal: controller.signal,
isCurrent: () =>
!destroyed &&
myGen === generation &&
!controller.signal.aborted &&
current(),
};
}
function installedOutcome(anno, outcome) {
if (outcome?.status === 'cancelled')
throw new Error('Annotation installation cancelled');
anno.pendingOutline = false;
if (outcome?.status === 'timeout') {
anno.ring = null;
anno.polygons = null;
anno.footprintKind = null;
anno.outlineUnavailable = true;
renderer.update(anno);
}
}
function ensureTicking() {
if (destroyed || tickHandle != null) return;
const tick = () => {
@@ -226,9 +323,14 @@ export function createAnnotationEngine({
changed = true;
}
if (anno.alpha <= 0 && anno.expiring) {
annotations.delete(anno.id);
renderer.remove(anno);
changed = true;
try {
removeRendered(anno);
annotations.delete(anno.id);
changed = true;
} catch {
// Leave failed cleanup addressable without stopping every other mark's tick.
anno.cleanupPending = true;
}
continue;
}
if (pendingAnimation(anno, now)) pending = true;
@@ -285,6 +387,58 @@ export function createAnnotationEngine({
const results = [];
const ids = [];
const installations = [];
const trackBuildingInstall = (
anno,
resolved,
spec,
completion,
ownership,
duplicate = false,
) => {
anno.pendingOutline = true;
ensureTicking();
const index = results.length;
results.push(null);
installations.push(
Promise.resolve(completion)
.then((outcome) => {
if (!ownership.isCurrent())
throw new Error('Annotation superseded');
installedOutcome(anno, outcome);
results[index] = {
...okResult(anno, resolved, anno.id),
...(duplicate ? { duplicate: true } : {}),
};
if (
typeof resolved.resolveOutline === 'function' &&
!anno.outlineUnavailable
)
startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
})
.catch((error) => {
anno.pendingOutline = false;
anno.outlineUnavailable = true;
try {
removeRendered(anno);
if (annotations.get(anno.id) === anno)
annotations.delete(anno.id);
} catch {
/* keep failed cleanup addressable */
}
syncAnnotationHold();
results[index] = failResult(
spec,
error?.message || 'Annotation installation failed',
);
}),
);
};
let firstAnchor = null;
let capped = false;
@@ -336,7 +490,12 @@ export function createAnnotationEngine({
// A progressive replacement inherits the dup's already-resolved geometry so an
// outline never visibly vanishes during a recaption; its own upgrade (below)
// refreshes it — usually instantly from the footprint cache.
if (anno.pendingOutline && !anno.ring && dup.ring) {
if (
anno.pendingOutline &&
!anno.ring &&
dup.ring &&
!dup.pendingOutline
) {
anno.ring = dup.ring;
anno.polygons = dup.polygons || null;
anno.footprintKind = dup.footprintKind || null;
@@ -344,11 +503,121 @@ export function createAnnotationEngine({
anno.synthesized = Boolean(dup.synthesized);
anno.anchor = { ...dup.anchor };
}
if (anno.ring?.length >= 3 && anno.footprintKind === 'building') {
if (dup.pendingOutline) {
// No valid old volume exists: replace its pending point and its task,
// so an older installation failure cannot erase the newer intent.
removeRendered(dup);
annotations.delete(dup.id);
annotations.set(anno.id, anno);
syncAnnotationHold();
const ownership = installOptions(anno, controller, myGen);
try {
const completion = renderer.add(anno, ownership);
trackBuildingInstall(
anno,
resolved,
spec,
completion,
ownership,
true,
);
} catch (error) {
rollbackRendererState(anno);
annotations.delete(anno.id);
syncAnnotationHold();
throw error;
}
continue;
}
// A recaption inherits the current mark's visible opacity while sampling.
anno.alpha = dup.alpha;
cancelReplacement(dup);
dup._pendingReplacement = anno;
const ownership = installOptions(
anno,
controller,
myGen,
() =>
annotations.get(dup.id) === dup &&
dup._pendingReplacement === anno,
);
let completion;
try {
completion = renderer.add(anno, {
...ownership,
pendingVisible: false,
});
} catch (error) {
if (dup._pendingReplacement === anno)
dup._pendingReplacement = null;
try {
removeRendered(anno);
} catch {
retainCleanup(dup, anno);
}
throw error;
}
const index = results.length;
results.push(null);
anno.pendingOutline = true;
installations.push(
Promise.resolve(completion)
.then((outcome) => {
if (!ownership.isCurrent())
throw new Error('Replacement superseded');
if (outcome?.status === 'timeout')
throw new Error(
'Building refresh timed out; previous outline retained',
);
installedOutcome(anno, outcome);
// No await between releasing the old slot and reserving its successor.
dup._pendingReplacement = null;
retainCleanup(anno, dup);
annotations.delete(dup.id);
annotations.set(anno.id, anno);
syncAnnotationHold();
ensureTicking();
try {
cleanupRetired(anno);
} catch {
/* new route is valid; report pending old-route cleanup */
}
results[index] = {
...okResult(anno, resolved, anno.id),
duplicate: true,
};
if (typeof resolved.resolveOutline === 'function')
startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
})
.catch((error) => {
if (dup._pendingReplacement === anno)
dup._pendingReplacement = null;
try {
removeRendered(anno);
} catch {
// Keep failed partial replacement cleanup reachable through the old slot.
if (annotations.get(dup.id) === dup)
retainCleanup(dup, anno);
}
results[index] = failResult(
spec,
error?.message || 'Building refresh failed',
);
}),
);
continue;
}
annotations.delete(dup.id);
annotations.set(anno.id, anno);
syncAnnotationHold();
try {
renderer.remove(dup);
removeRendered(dup);
renderer.add(anno);
} catch (swapError) {
// Same contract as the fresh path: a renderer/WebGL throw must
@@ -375,6 +644,7 @@ export function createAnnotationEngine({
duplicate: true,
});
} else {
cancelReplacement(dup);
// Identical re-narration → refresh the existing mark's lifecycle in place (keep
// it visible whether persistent or TTL-based, and revive a fading-out one).
if (persist) {
@@ -400,7 +670,7 @@ export function createAnnotationEngine({
// Same result builder as the fresh path (full metadata) but the existing id —
// route fallback / approximate honesty fields survive a re-narration.
results.push({
...okResult(anno, resolved, dup.id),
...okResult(dup, resolved, dup.id),
duplicate: true,
});
}
@@ -416,7 +686,12 @@ export function createAnnotationEngine({
annotations.set(anno.id, anno);
syncAnnotationHold();
try {
renderer.add(anno);
const ownership = installOptions(anno, controller, myGen);
const completion = renderer.add(anno, ownership);
if (completion?.then) {
trackBuildingInstall(anno, resolved, spec, completion, ownership);
continue;
}
} catch (addError) {
// A failed renderer add must not retain a phantom annotation (and
// with it a phantom continuous-render hold). (perf wave 2 fix)
@@ -448,6 +723,40 @@ export function createAnnotationEngine({
);
}
}
if (installations.length) {
await Promise.all(installations);
for (let index = 0; index < results.length; index += 1) {
const result = results[index];
if (!result?.ok) continue;
const current = annotations.get(result.id);
if (
!current ||
(current.cleanupPending && current.outlineUnavailable)
) {
results[index] = failResult(
list[index],
'Annotation installation failed or superseded',
);
continue;
}
result.outline = Boolean(current.ring) && !current.pendingOutline;
if (current.pendingOutline) result.outlinePending = true;
else delete result.outlinePending;
if (current.outlineUnavailable) {
result.outlineUnavailable = true;
result.message = 'Detailed outline unavailable';
} else delete result.outlineUnavailable;
if (current.cleanupPending || current._retiredRoutes?.size)
result.cleanupPending = true;
else delete result.cleanupPending;
}
ids.splice(
0,
ids.length,
...results.filter((r) => r?.ok).map((r) => r.id),
);
firstAnchor = annotations.get(ids[0]) || null;
}
} finally {
releaseController(controller);
}
@@ -671,6 +980,7 @@ export function createAnnotationEngine({
// proxy caches the late completion, so the retry is usually a warm cache hit.
fp = await resolveOutlineWithRetry(resolveOutline, {
delaysMs: outlineRetryDelaysMs,
signal: controller.signal,
isStale: () =>
myGen !== generation ||
controller.signal.aborted ||
@@ -681,8 +991,12 @@ export function createAnnotationEngine({
anno.pendingOutline = false;
anno.outlineUnavailable = isUnavailableCapability(fp);
const installStart = performance.now();
let groundInstallation = null;
let schedulingMs = 0;
let completionStart = null;
if (anno.outlineUnavailable) renderer.update(anno);
if (fp && !anno.outlineUnavailable) {
anno.pendingOutline = true;
anno.ring = fp.ring;
anno.polygons = fp.polygons || null;
anno.footprintKind = fp.footprintKind || null;
@@ -697,7 +1011,15 @@ export function createAnnotationEngine({
};
}
// Re-route: ring presence decides world-drape vs screen-space in the renderer.
renderer.update(anno);
const ownership = installOptions(anno, controller, myGen);
const completion = renderer.update(anno, ownership);
if (completion?.then) {
schedulingMs = performance.now() - installStart;
groundInstallation = await completion;
completionStart = performance.now();
if (!ownership.isCurrent()) return;
installedOutcome(anno, groundInstallation);
} else anno.pendingOutline = false;
// GEOMETRY dedup, now that geometry is known: two DIFFERENT targets can resolve
// to the SAME polygon ("Marina" / "Marina District") — the identity-is-geometry
// invariant says they are ONE mark. The pending-phase dedup keys on target (it
@@ -705,30 +1027,31 @@ export function createAnnotationEngine({
// resolved area with the same anchor + ring, keeping THIS mark (latest caption
// wins, exactly like the add-time replace path). Not an auto-clear — it removes
// a second representation of the same mark, never a distinct one.
for (const other of annotations.values()) {
if (
other.id === anno.id ||
other.type !== 'area' ||
other.pendingOutline
)
continue;
if (
!other.anchor ||
Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 ||
Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4
)
continue;
if (Boolean(other.synthesized) !== Boolean(anno.synthesized))
continue;
if ((other.footprintKind || null) !== (anno.footprintKind || null))
continue;
if (!outlinesEqual(other, anno)) continue;
annotations.delete(other.id);
renderer.remove(other);
}
if (!anno.outlineUnavailable)
for (const other of annotations.values()) {
if (
other.id === anno.id ||
other.type !== 'area' ||
other.pendingOutline
)
continue;
if (
!other.anchor ||
Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 ||
Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4
)
continue;
if (Boolean(other.synthesized) !== Boolean(anno.synthesized))
continue;
if ((other.footprintKind || null) !== (anno.footprintKind || null))
continue;
if (!outlinesEqual(other, anno)) continue;
removeRendered(other);
annotations.delete(other.id);
}
}
renderer.sync(annotations);
if (fp && !anno.outlineUnavailable)
if (groundInstallation || (fp && !anno.outlineUnavailable))
phaseTiming(
'install',
installStart,
@@ -736,6 +1059,24 @@ export function createAnnotationEngine({
kind: anno.footprintKind || null,
parts: anno.polygons?.length || (anno.ring ? 1 : 0),
points: anno.ring?.length || 0,
...(groundInstallation?.timing
? {
groundSampling: groundInstallation.timing,
ownedWorkMs:
schedulingMs +
groundInstallation.timing.workMs +
performance.now() -
completionStart,
maxOwnedSliceMs: Math.max(
schedulingMs,
groundInstallation.timing.maxSliceMs,
groundInstallation.timing.lastSliceMs +
performance.now() -
completionStart,
),
installStatus: groundInstallation.status,
}
: {}),
},
'outlines',
);
@@ -756,6 +1097,37 @@ export function createAnnotationEngine({
: {}),
...(fp && anno.synthesized ? { approximate: true } : {}),
});
} catch (error) {
if (
myGen === generation &&
!controller.signal.aborted &&
annotations.get(anno.id) === anno
) {
anno.pendingOutline = false;
anno.outlineUnavailable = true;
try {
removeRendered(anno);
anno.ring = null;
anno.polygons = null;
anno.footprintKind = null;
renderer.add(anno);
} catch {
try {
removeRendered(anno);
annotations.delete(anno.id);
} catch {
/* retain failed partial state in the map for later cleanup */
}
syncAnnotationHold();
}
emitOutlineEvent({
id: anno.id,
label: anno.label || null,
target: anno.targetKey || null,
status: 'failed',
message: 'Outline installation failed',
});
}
} finally {
if (anno._outlineTask === task) anno._outlineTask = null;
releaseController(controller);
@@ -776,6 +1148,9 @@ export function createAnnotationEngine({
longitude: round5(anno.anchor.lon),
resolvedVia: resolved.source,
outline: Boolean(anno.ring),
...(anno.cleanupPending || anno._retiredRoutes?.size
? { cleanupPending: true }
: {}),
// The anchor is placed and returned fast; the footprint is still being traced and
// will appear on its own (or the mark honestly stays a point). Lets the voice
// layer narrate without waiting out a slow Overpass — and without calling the
@@ -979,10 +1354,18 @@ export function createAnnotationEngine({
abortPending();
dropQueuedOutlineUpgrades();
if (!annotations.size) return;
for (const anno of annotations.values()) renderer.remove(anno);
annotations.clear();
let failure;
for (const anno of annotations.values()) {
try {
removeRendered(anno);
annotations.delete(anno.id);
} catch (error) {
failure ||= error;
}
}
renderer.sync(annotations);
syncAnnotationHold();
if (failure) throw failure;
}
/** Begin a graceful fade-out of everything, then remove. */
@@ -1151,12 +1534,14 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts',
color: 'amber',
},
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio (former Army base)',
color: 'green',
footprint: true,
@@ -1164,6 +1549,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm',
color: 'cyan',
},
@@ -1171,6 +1557,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina',
},
],
@@ -1200,6 +1587,7 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts',
color: 'amber',
},
@@ -1214,6 +1602,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina',
color: 'cyan',
},
@@ -1237,6 +1626,7 @@ export function createAnnotationEngine({
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio — a former Army base',
color: 'green',
footprint: true,
@@ -1251,6 +1641,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm',
color: 'red',
},
@@ -1264,6 +1655,7 @@ export function createAnnotationEngine({
{
type: 'route',
color: 'amber',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
@@ -1305,8 +1697,17 @@ export function createAnnotationEngine({
return engine;
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
function wait(ms, signal) {
return new Promise((resolve) => {
if (signal?.aborted) return resolve();
const finish = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', finish);
resolve();
};
const timer = setTimeout(finish, ms);
signal?.addEventListener('abort', finish, { once: true });
});
}
/** Exact per-vertex ring equality ([[lon,lat],…], ~1m epsilon) — the geometry-identity test. */
@@ -1615,16 +2016,7 @@ async function fetchRoute(coordPairs, mode, signal, service) {
}
function greatCircleM(a, b) {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat);
const la2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
return greatCircleMeters(a, b);
}
function formatDistance(m) {
+409 -15
View File
@@ -216,9 +216,16 @@ test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight',
});
test('outline queue: clear drops queued-but-unstarted upgrades without a later fetch', async (t) => {
let engine;
// Destroy while the browser/timer stubs are still installed.
t.after(() => engine?.destroy());
installAnimationFrameStubs(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { renderer } = fakeRenderer();
renderer.destroy = () => {};
let fetchesStarted = 0;
const releases = [];
const resolveTarget = async ({ target, signal }) => ({
lon: Number(target.slice('clear-'.length)),
lat: 10,
@@ -226,26 +233,57 @@ test('outline queue: clear drops queued-but-unstarted upgrades without a later f
label: target,
source: 'fake',
ring: null,
resolveOutline: () => new Promise((resolve) => {
fetchesStarted += 1;
const finish = () => resolve(undefined);
if (signal.aborted) finish();
else signal.addEventListener('abort', finish, { once: true });
}),
resolveOutline: () =>
new Promise((resolve) => {
fetchesStarted += 1;
const finish = () => resolve(undefined);
releases.push(finish);
if (signal.aborted) finish();
else signal.addEventListener('abort', finish, { once: true });
}),
});
engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
await engine.annotate(Array.from({ length: 8 }, (_, i) => ({
type: 'area',
target: `clear-${i}`,
footprint: true,
})));
await engine.annotate(
Array.from({ length: 8 }, (_, i) => ({
type: 'area',
target: `clear-${i}`,
footprint: true,
})),
);
assert.equal(fetchesStarted, 2);
// Both workers enter their real backoff while the remaining six stay queued.
releases[0]();
releases[1]();
await flushMicrotasks();
engine.clear();
await flushMicrotasks();
assert.equal(engine.count(), 0);
assert.equal(fetchesStarted, 2, 'the six queued upgrades were dropped on clear');
assert.equal(
fetchesStarted,
2,
'the six queued upgrades were dropped on clear',
);
await engine.annotate(
[8, 9].map((i) => ({
type: 'area',
target: `clear-${i}`,
footprint: true,
})),
);
await flushMicrotasks();
assert.equal(
fetchesStarted,
4,
'cleared backoff timers release both workers without advancing time',
);
});
test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429 stops', async (t) => {
@@ -313,7 +351,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
const requestTimes = [];
globalThis.fetch = async () => {
globalThis.fetch = async (url) => {
if (String(url).endsWith('/status'))
return Response.json({ configured: true });
requestTimes.push(Date.now());
return httpFailure(500);
};
@@ -402,7 +442,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
};
let overpassCall = 0;
globalThis.fetch = async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({
status: 'OK',
results: [{
@@ -516,6 +556,360 @@ function throwingRendererHarness() {
const FIXED_POINT = { lon: -97.7431, lat: 30.2672, height: 0 };
// Building installation is a renderer-owned async interval after location resolution.
function controlledBuildingRenderer() {
const { renderer, calls } = fakeRenderer();
const pending = [];
const live = new Set();
const enqueue = (anno, options = {}) =>
new Promise((resolve, reject) => {
let settled = false;
const finish = (status = 'installed', error) => {
if (settled) return;
settled = true;
options.signal?.removeEventListener('abort', abort);
if (status === 'installed' && (options.isCurrent?.() ?? true))
live.add(anno.id);
if (error) reject(error);
else
resolve({
status,
timing: { workMs: 1, maxSliceMs: 1, lastSliceMs: 1 },
});
};
const abort = () => finish('cancelled');
options.signal?.addEventListener('abort', abort, { once: true });
pending.push({ anno, options, finish });
});
renderer.add = (anno, options) => {
calls.add += 1;
if (anno.footprintKind === 'building' && anno.ring)
return enqueue(anno, options);
live.add(anno.id);
};
renderer.update = (anno, options) => {
calls.update += 1;
if (anno.footprintKind === 'building' && anno.ring)
return enqueue(anno, options);
};
renderer.remove = (anno) => {
calls.remove += 1;
live.delete(anno.id);
pending
.filter((item) => item.anno === anno)
.forEach((item) => item.finish('cancelled'));
};
renderer.destroy = () => pending.forEach((item) => item.finish('cancelled'));
return { renderer, calls, pending, live };
}
const BUILDING_FP = {
...FIXED_POINT,
ring: [
[-97.744, 30.267],
[-97.743, 30.267],
[-97.743, 30.268],
[-97.744, 30.267],
],
footprintKind: 'building',
buildingHeight: 20,
};
test('fresh async building batch reserves slots together and releases failed/cleared installs', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {},
renderer: h.renderer,
resolveTarget: async ({ target }) => ({
...BUILDING_FP,
lon: target === 'second' ? -97.7 : BUILDING_FP.lon,
}),
});
const result = engine.annotate([
{ type: 'area', target: 'first', label: 'First' },
{ type: 'area', target: 'second', label: 'Second' },
]);
await flushMicrotasks();
assert.equal(
h.pending.length,
2,
'both installs enqueue without serial awaits',
);
assert.equal(engine.count(), 2, 'pending marks reserve the cap');
h.pending[0].finish('failed', new Error('async WebGL failure'));
h.pending[1].finish('timeout');
const completed = await result;
assert.equal(completed.drawn, 1);
assert.equal(completed.failed, 1);
assert.equal(engine.count(), 1);
assert.equal(engine.list()[0].outlineUnavailable, true);
assert.equal(engine.list()[0].ring, null);
assert.equal(engine.list()[0].pendingOutline, false);
engine.clear();
assert.equal(engine.count(), 0);
assert.ok(!getRenderGovernorDiagnostics().holds.includes('annotations'));
const cancelled = engine.annotate([{ type: 'area', target: 'third' }]);
await flushMicrotasks();
const task = h.pending.at(-1);
engine.clear();
assert.equal(task.options.signal.aborted, true);
task.finish();
assert.equal((await cancelled).aborted, true);
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
});
test('outline workers retain completion ownership and clear cancels resolved-but-uninstalled geometry', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const started = [];
const engine = createAnnotationEngine({
viewer: {},
renderer: h.renderer,
resolveTarget: async ({ target }) => ({
...FIXED_POINT,
lon: -97 + Number(target),
resolveOutline: async () => {
started.push(target);
return { ...BUILDING_FP, lon: -97 + Number(target) };
},
}),
});
const events = [];
engine.onOutlineEvent((event) => events.push(event));
const result = await engine.annotate(
['0', '1', '2'].map((target) => ({ type: 'area', target })),
);
assert.equal(result.drawn, 3);
await flushMicrotasks();
assert.equal(h.pending.length, 2);
assert.deepEqual(started, ['0', '1']);
assert.equal(events.length, 0, 'resolver finish is not installation finish');
assert.ok(engine.list().every((anno) => anno.pendingOutline));
h.pending[0].finish();
await flushMicrotasks();
assert.equal(events.length, 1);
assert.equal(events[0].status, 'resolved');
assert.deepEqual(started, ['0', '1', '2']);
engine.clear();
await flushMicrotasks();
h.pending.forEach((task) => {
assert.equal(task.options.signal.aborted, true);
task.finish();
});
await flushMicrotasks();
assert.equal(events.length, 1, 'no late outcome on cleared marks');
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
});
test('building recaption timeout retains old route and a newer replacement owns the swap', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {},
renderer: h.renderer,
resolveTarget: async () => ({ ...BUILDING_FP }),
});
const initial = engine.annotate([
{ type: 'area', target: 'Capitol', label: 'Old' },
]);
await flushMicrotasks();
h.pending[0].finish();
await initial;
const old = engine.list()[0];
const refresh = (label) =>
engine.annotate([{ type: 'area', target: 'Capitol', label }]);
const timed = refresh('Timed');
await flushMicrotasks();
assert.equal(engine.list()[0], old);
assert.ok(h.live.has(old.id));
h.pending.at(-1).finish('timeout');
assert.equal((await timed).failed, 1);
assert.equal(engine.list()[0], old);
assert.equal(old.label, 'Old');
assert.ok(h.live.has(old.id));
const losing = refresh('Losing');
await flushMicrotasks();
const loser = h.pending.at(-1);
const winning = refresh('Winning');
await flushMicrotasks();
const winner = h.pending.at(-1);
assert.notEqual(loser.anno, winner.anno);
assert.equal(loser.options.isCurrent(), false);
winner.finish();
loser.finish();
assert.equal((await losing).failed, 1);
assert.equal((await winning).drawn, 1);
assert.equal(engine.list()[0].label, 'Winning');
assert.equal(h.live.size, 1);
assert.ok(!h.live.has(old.id));
const late = refresh('After clear');
await flushMicrotasks();
const lateTask = h.pending.at(-1);
engine.clear();
lateTask.finish();
assert.equal((await late).aborted, true);
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
});
test('replacement cleanup failure still removes the old route and retains failed cleanup ownership', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
await flushMicrotasks();
h.pending[0].finish();
await initial;
const old = engine.list()[0];
const refresh = engine.annotate([{ type: 'area', target: 'Capitol', label: 'New' }]);
await flushMicrotasks();
const replacement = h.pending.at(-1).anno;
const originalRemove = h.renderer.remove;
const removals = [];
let fail = true;
h.renderer.remove = (anno) => {
removals.push(anno);
if (anno === replacement && fail) throw new Error('replacement cleanup failed');
originalRemove(anno);
};
assert.throws(() => engine.clear(), /replacement cleanup failed/);
assert.ok(removals.includes(old), 'old removal is attempted even when replacement removal throws');
assert.equal(old._pendingReplacement, null, 'cleanup failure cannot retain install authority');
assert.ok(old._retiredRoutes.has(replacement));
assert.equal(engine.list()[0], old, 'failed cleanup remains reachable');
await flushMicrotasks();
fail = false;
engine.clear();
await refresh;
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
assert.equal(old._pendingReplacement, null);
});
test('identical refresh revokes an older building replacement even if cancellation cleanup fails', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
await flushMicrotasks(); h.pending[0].finish(); await initial;
const old = engine.list()[0];
const pending = engine.annotate([{ type: 'area', target: 'Capitol', label: 'New' }]);
await flushMicrotasks();
const task = h.pending.at(-1);
const remove = h.renderer.remove;
let fail = true;
h.renderer.remove = (anno) => {
if (anno === task.anno && fail) throw new Error('cancel cleanup failed');
remove(anno);
};
const identical = await engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
assert.equal(identical.results[0].id, old.id);
assert.equal(identical.results[0].cleanupPending, true);
assert.equal(task.options.isCurrent(), false, 'newest identical ask revokes install authority immediately');
task.finish();
assert.equal((await pending).failed, 1);
assert.equal(engine.list()[0], old);
assert.equal(old.label, 'Old');
fail = false;
engine.clear();
assert.equal(engine.count(), 0);
});
test('successful replacement survives failed old-route retirement and keeps cleanup addressable', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
await flushMicrotasks(); h.pending[0].finish(); await initial;
const old = engine.list()[0];
const remove = h.renderer.remove;
let fail = true;
h.renderer.remove = (anno) => {
if (anno === old && fail) throw new Error('retirement failed');
remove(anno);
};
const refresh = engine.annotate([{ type: 'area', target: 'Capitol', label: 'New' }]);
await flushMicrotasks(); h.pending.at(-1).finish();
const completed = await refresh;
const current = engine.list()[0];
assert.equal(completed.drawn, 1);
assert.equal(completed.results[0].cleanupPending, true);
assert.equal(current.label, 'New');
assert.ok(h.live.has(current.id), 'valid installed replacement is not rolled back by old teardown failure');
assert.equal(current._pendingReplacement, undefined);
assert.ok(current._retiredRoutes.has(old));
fail = false;
engine.clear();
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
});
test('pending fresh predecessor failure cannot erase a newer building intent', async (t) => {
installAnimationFrameStubs(t);
const frames = [];
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; };
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const first = engine.annotate([{ type: 'area', target: 'Capitol', label: 'First' }]);
await flushMicrotasks();
const predecessor = h.pending[0];
assert.ok(frames.length > 0, 'fresh pending point starts its existing fade tick before batch settlement');
const remove = h.renderer.remove;
h.renderer.remove = (anno) => {
if (anno !== predecessor.anno) remove(anno);
};
const second = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Second' }]);
await flushMicrotasks();
const successor = h.pending.at(-1);
assert.notEqual(successor.anno, predecessor.anno);
predecessor.finish('failed', new Error('late old install failure'));
await flushMicrotasks();
assert.equal(engine.list()[0], successor.anno);
assert.equal(successor.options.isCurrent(), true);
successor.finish();
assert.equal((await first).failed, 1);
assert.equal((await second).drawn, 1);
assert.equal(engine.list()[0].label, 'Second');
engine.clear();
});
test('duplicate pending building results reflect final failure rather than a dead id', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const spec = { type: 'area', target: 'Capitol', label: 'Same' };
const batch = engine.annotate([spec, spec]);
await flushMicrotasks();
assert.equal(h.pending.length, 1);
h.pending[0].finish('failed', new Error('failed shared install'));
const result = await batch;
assert.equal(result.drawn, 0);
assert.equal(result.failed, 2);
assert.deepEqual(result.ids, []);
assert.equal(engine.count(), 0);
});
test('duplicate-replacement: a renderer throw leaves no phantom mark and no leaked hold', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
+10 -10
View File
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
assert.match(String(url), /^\/api\/google\/geocode\?/);
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -273,7 +273,7 @@ for (const fixture of [
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: fixture.lat,
lon: fixture.lon,
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
const query = new URL(String(url)).searchParams.get('address');
return { json: async () => geocodePayload({
lat: 39.7817,
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
]) {
installGoogleMocks(t, async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({ status: 'OK', results: [{
formatted_address: 'Washington, DC 20004, USA',
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
return {
ok: true,
json: async () => ({
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
high: { latitude: 30.28, longitude: -97.68 },
});
// Google is asked first and exactly once; one Photon call answers it.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
});
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
installGoogleMocks(t, async (url, init) => {
const href = String(url);
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
if (href.startsWith('https://photon.komoot.io/')) {
return {
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (!photonReachable) throw new Error('network down');
return photonUp;
});
+27 -23
View File
@@ -1,3 +1,5 @@
import { t, tn } from '../i18n/index.js';
/**
* Manual whiteboard drawing: the pure half.
*
@@ -18,6 +20,8 @@
* `drawTool.js`.
*/
import { greatCircleMeters } from '../geoDistance.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/**
@@ -27,10 +31,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
*/
export const MAX_VERTICES = 512;
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
export const MIN_VERTEX_SEPARATION_M = 0.5;
const MIN_VERTEX_SEPARATION_M = 0.5;
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
export const MIN_PATH_LENGTH_M = 1;
export const MIN_AREA_M2 = 1;
const MIN_PATH_LENGTH_M = 1;
const MIN_AREA_M2 = 1;
/** @param {string} shape @returns {'area'|'line'|'pin'} */
export function normalizeShape(shape) {
@@ -46,15 +50,8 @@ export function createDrawSession(shape = 'area') {
}
/** Great-circle distance in metres between two {lon, lat} points. */
export function greatCircleM(a, b) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
function greatCircleM(a, b) {
return greatCircleMeters(a, b);
}
/**
@@ -211,9 +208,10 @@ export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
return m >= 1000
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
: `${Math.round(m)} m`;
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const whole = Math.round(m);
if (whole < 1000) return `${whole} m`;
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
@@ -284,20 +282,26 @@ export function closeRing(pairs) {
/** One line of guidance for the person drawing, by state. */
export function drawHint(session) {
if (!session) return 'Pick a shape, then click the map.';
if (!session) return t('Pick a shape, then click the map.');
const n = session.vertices.length;
if (session.shape === 'pin')
return n
? 'Enter to place the pin, Esc to cancel.'
: 'Click where the pin goes.';
? t('Enter to place the pin, Esc to cancel.')
: t('Click where the pin goes.');
const need = MIN_VERTICES[session.shape] - n;
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
if (need > 0)
return tn(need, 'Click {count} more point.', 'Click {count} more points.');
if (finishReason(session) === 'degenerate') {
return session.shape === 'area'
? 'Those points are in a line — move one off it to enclose an area.'
: 'That line has no length — click somewhere further away.';
? t('Those points are in a line — move one off it to enclose an area.')
: t('That line has no length — click somewhere further away.');
}
const full =
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
n >= MAX_VERTICES
? t(' · {max}-point limit reached', { max: MAX_VERTICES })
: '';
return t(
'{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}',
{ measure: formatMeasure(session), full },
);
}
+15
View File
@@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', (
assert.equal(wrapLongitude(180), -180);
assert.equal(unwrapLongitudes([]).length, 0);
});
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const lat = 999.6 / metresPerDegree;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat });
const metres = pathLengthM(line.vertices);
assert.ok(
metres >= 999.5 && metres < 1000,
`length should round to 1000 m, got ${metres}`,
);
assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /);
});
+42 -17
View File
@@ -30,12 +30,14 @@
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { createSegmentedControl } from '../ui/segmentedControl.js';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
import {
DRAW_SHAPES,
MAX_VERTICES,
@@ -48,8 +50,9 @@ import {
removeLastVertex,
} from './drawMode.js';
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const DRAW_POINTER_OWNER = 'draw';
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
@@ -100,6 +103,17 @@ export function initDrawTool({ viewer, annotations }) {
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
// The board lives only in memory: switching language (a reload) asks first
// while it holds a lasting mark or a shape is half drawn.
const unregisterUnsavedWork = registerUnsavedWork(() => {
if (destroyed) return null;
const lasting = (annotations.list?.() || []).some(
(mark) => mark && mark.ttlMs == null && !mark.expiring,
);
return lasting || session?.vertices?.length > 0
? msg('drawings on the map')
: null;
});
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
@@ -197,10 +211,13 @@ export function initDrawTool({ viewer, annotations }) {
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
t(
'That shape already has {count} points — finish it or press Backspace.',
{ count: MAX_VERTICES },
),
);
else if (reason === 'invalid')
setHint('That point is off the globe — click on the world.');
setHint(t('That point is off the globe — click on the world.'));
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
@@ -236,11 +253,15 @@ export function initDrawTool({ viewer, annotations }) {
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint('That shape could not be placed.');
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(`Could not place the shape: ${error?.message || error}`);
setHint(
t('Could not place the shape: {error}', {
error: error?.message || error,
}),
);
return null;
}
};
@@ -258,7 +279,7 @@ export function initDrawTool({ viewer, annotations }) {
if (session) session = createDrawSession(shape);
annotations.clear();
syncPreview();
if (!destroyed && !active) setHint('Board cleared.');
if (!destroyed && !active) setHint(t('Board cleared.'));
};
// ---- keys: only while drawing, never while typing in another field ----
@@ -324,7 +345,11 @@ export function initDrawTool({ viewer, annotations }) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(`${pointerOwner()} is using the pointer — close it first.`);
setHint(
t('{owner} is using the pointer — close it first.', {
owner: translateKnown(pointerOwnerName(pointerOwner())),
}),
);
return active;
}
}
@@ -408,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) {
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
for (const btn of shapeButtons) {
listen(btn, 'click', () => {
shape = normalizeShape(btn.dataset.shape);
for (const other of shapeButtons) {
const on = other === btn;
other.classList.toggle('active', on);
other.setAttribute('aria-checked', String(on));
}
const shapeControl = createSegmentedControl({
buttons: shapeButtons,
valueOf: (button) => button.dataset.shape,
listen,
onChange: (value) => {
shape = normalizeShape(value);
shapeControl.update(shape);
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
});
}
},
});
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
@@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
generation += 1;
if (active) setActive(false);
destroyed = true;
unregisterUnsavedWork();
releaseSceneHandler();
releasePointer(lease);
lease = null;
+29 -1
View File
@@ -6,6 +6,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { initDrawTool } from './drawTool.js';
import { describeUnsavedWork } from '../i18n/index.js';
import {
isPointerFree,
pointerOwner,
@@ -240,6 +241,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
/* ── the tests ─────────────────────────────────────────────────────────── */
// First in the file: tools left by later tests would answer the same registry.
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
const h = harness();
try {
let marks = [];
h.annotations.list = () => marks;
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
marks = [{ id: 'b', ttlMs: null, expiring: false }];
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
marks = [];
h.element('draw-toggle').emit('click');
h.tool.addVertex(-97.75, 30.26);
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
marks = [{ id: 'c', ttlMs: null, expiring: false }];
await h.tool.destroy();
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
} finally {
h.restore();
}
});
test('turning Draw on takes a lease and turning it off gives it back', async () => {
const h = harness();
try {
@@ -263,7 +287,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
h.element('draw-toggle').emit('click');
assert.equal(h.tool.active, false, 'it must not half-start');
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
assert.match(h.element('draw-hint').textContent, /directions/);
// Named by its tool, not by the internal owner id.
assert.equal(
h.element('draw-hint').textContent,
'Directions is using the pointer — close it first.',
);
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
releasePointer(other);
+116 -7
View File
@@ -24,7 +24,7 @@ export function createHybridAnnotationRenderer(viewer) {
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
const routed = new Map();
function add(anno) {
function add(anno, options = {}) {
const entry = {};
// Route the entry FIRST, then fill it in. A sub-renderer throw (WebGL loss
// mid-add) otherwise stranded whatever the earlier sub-renderer had already
@@ -36,7 +36,20 @@ export function createHybridAnnotationRenderer(viewer) {
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
// Drape the footprint in world space, but without its own label...
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
const completion = world.add(
entry.worldProxy,
worldOptions(anno, entry, options),
);
if (completion?.then) {
if (options.pendingVisible !== false) {
entry.screenProxy = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
screen.add(entry.screenProxy);
}
return finishBuilding(anno, entry, completion, options);
}
// ...and place the caption as a screen-space callout at the centroid.
if (anno.label) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
@@ -71,27 +84,45 @@ export function createHybridAnnotationRenderer(viewer) {
* World geometry is new, but the reticle's SVG group is converted in place to
* the centroid callout so there is no overlapping fade-out/fade-in pair.
*/
function update(anno) {
function update(anno, options = {}) {
const entry = routed.get(anno.id);
if (!entry) {
add(anno);
return;
return add(anno, options);
}
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
if (anno.outlineUnavailable && entry.screenProxy)
if (anno.outlineUnavailable && entry.screenProxy) {
entry.screenProxy = liveProxy(anno, { ring: null });
screen.update(entry.screenProxy);
}
return;
}
if (!entry.worldProxy) {
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
const completion = world.add(
entry.worldProxy,
worldOptions(anno, entry, options),
);
if (completion?.then) {
if (entry.screenProxy) {
const pending = liveProxy(anno, { ring: null, pendingOutline: true });
screen.update(pending);
entry.screenProxy = pending;
}
return finishBuilding(anno, entry, completion, options);
}
}
updateCaption(anno, entry);
}
function updateCaption(anno, entry) {
const screenProxy = liveProxy(anno, { type: 'label', ring: null });
if (entry.screenProxy) {
screen.update(screenProxy, { empty: !anno.label });
} else if (anno.label) {
// Publish before add: a partially created caption must remain removable.
entry.screenProxy = screenProxy;
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy
@@ -101,6 +132,84 @@ export function createHybridAnnotationRenderer(viewer) {
: null;
}
function finishBuilding(anno, entry, completion, options) {
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();
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;
}
return { ...result, status: 'timeout' };
}
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 */
}
} else world.remove(proxy);
throw error;
});
}
function worldOptions(anno, entry, options) {
const proxy = entry.worldProxy;
return {
...options,
isCurrent: () =>
routed.get(anno.id) === entry &&
entry.worldProxy === proxy &&
(options.isCurrent?.() ?? true),
};
}
/**
* Release both routes. Tolerates PARTIAL state (only one sub-renderer got
* its content) and an absent id, so it doubles as the rollback path for a
@@ -215,16 +215,19 @@ function installBrowserGlobals(t) {
function fakeViewer(lon, lat) {
const camera = cameraLookingAt(lon, lat);
const dataSources = [];
const frames = controlledFrames();
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: { addEventListener() {}, removeEventListener() {} },
postRender: frames.event,
requestRender() {},
};
return {
dataSources,
frames,
viewer: {
scene,
camera,
@@ -244,6 +247,32 @@ function fakeViewer(lon, lat) {
};
}
function controlledFrames() {
const listeners = new Set();
return {
event: {
addEventListener(listener) { listeners.add(listener); },
removeEventListener(listener) { listeners.delete(listener); },
},
emit() { for (const listener of [...listeners]) listener(); },
snapshot() { return [...listeners]; },
get size() { return listeners.size; },
};
}
function buildingAnnotation(id, offset = 0) {
const ring = [
[-99 + offset, 31], [-98.999 + offset, 31],
[-98.999 + offset, 31.001], [-99 + offset, 31.001],
[-99 + offset, 31],
];
return {
id, type: 'area', color: 'primary', label: null, alpha: 1,
anchor: { ...naiveRingCentroid(ring), height: 100 },
ring, footprintKind: 'building', buildingHeight: 20,
};
}
function annotationGroups(svg) {
return svg.children.filter((child) => child.classList.contains('gev-anno'));
}
@@ -256,9 +285,11 @@ function naiveRingCentroid(ring) {
return { lon: totals.lon / ring.length, lat: totals.lat / ring.length, height: 0 };
}
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
test('hybrid outline upgrade preserves the screen group and adds world geometry', async (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalWindow = globalThis.window;
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
const originalBrowserConstructors = new Map([
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
['HTMLImageElement', globalThis.HTMLImageElement],
@@ -269,6 +300,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
t.after(() => {
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
@@ -281,13 +314,15 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
const camera = cameraLookingAt(-99, 31);
const dataSources = [];
const frames = controlledFrames();
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: { addEventListener() {}, removeEventListener() {} },
postRender: frames.event,
requestRender() {},
};
const viewer = {
scene,
@@ -356,6 +391,62 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polyline).length, 1);
assert.equal(Object.hasOwn(anno, '_entities'), false, 'world entity state stays on the inherited proxy');
const outer = [
[-99, 31],
[-98.999, 31],
[-98.999, 31.001],
[-99, 31.001],
[-99, 31],
];
const hole = [
[-98.9998, 31.0002],
[-98.9996, 31.0002],
[-98.9996, 31.0004],
[-98.9998, 31.0004],
[-98.9998, 31.0002],
];
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
const building = {
...anno,
id: 'anno-multipart-building',
label: 'Building',
anchor: { lon: -99, lat: 31, height: 0 },
ring: outer,
polygons: [[outer, hole], [island]],
footprintKind: 'building',
buildingHeight: 20,
};
const completion = renderer.add(building);
assert.equal(dataSources[0].entities.values.length, 2, 'building geometry waits for all probes');
for (let frame = 0; frame < 8; frame += 1) frames.emit();
assert.equal((await completion).status, 'installed');
const added = dataSources[0].entities.values.slice(2);
const fills = added.filter(
(entity) => entity.polygon && entity.polygon.fill?.getValue() !== false,
);
assert.equal(
fills.length,
2,
'every building part gets a classification volume',
);
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
assert.equal(fills[1].polygon.hierarchy.getValue().holes.length, 0);
assert.equal(
fills[0].polygon.material,
fills[1].polygon.material,
'parts share the fill material',
);
assert.equal(
added.filter((entity) => entity.polyline).length,
3,
'all outer and hole outlines are retained',
);
renderer.remove(building);
assert.equal(
dataSources[0].entities.values.length,
2,
'multipart removal leaves only the earlier area',
);
renderer.destroy();
});
@@ -499,3 +590,168 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
assert.equal(annotationGroups(svg).length, 1);
renderer.destroy();
});
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);
const renderer = createHybridAnnotationRenderer(viewer);
t.after(() => renderer.destroy());
const first = buildingAnnotation('sampled-building');
const second = buildingAnnotation('fallback-building', 0.01);
const probes = [];
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
viewer.scene.clampToHeight = (position) => {
const cartographic = Cesium.Cartographic.fromCartesian(position);
const lon = Cesium.Math.toDegrees(cartographic.longitude);
const lat = Cesium.Math.toDegrees(cartographic.latitude);
probes.push([lon, lat]);
const outcome = outcomes[probes.length - 1];
if (outcome instanceof Error) throw outcome;
return outcome == null ? undefined : Cesium.Cartesian3.fromDegrees(lon, lat, outcome);
};
// 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 });
assert.equal(probes.length, 0, 'enqueue must not probe synchronously');
assert.equal(dataSources[0].entities.values.length, 0);
for (let frame = 0; frame < 16; frame += 1) {
const before = probes.length;
frames.emit();
assert.equal(probes.length, before + 1, 'the two buildings share one total probe budget');
if (frame < 7) assert.equal(dataSources[0].entities.values.length, 0, 'no partial world parts');
}
const [installed, fallback] = await Promise.all([firstCompletion, secondCompletion]);
assert.equal(installed.status, 'installed');
assert.equal(fallback.status, 'installed');
assert.equal(installed.timing.probes, 8);
assert.equal(fallback.timing.probes, 8);
const centroid = naiveRingCentroid(first.ring);
const lonScale = 111320 * Math.cos(Cesium.Math.toRadians(centroid.lat));
const radius = Math.max(...first.ring.map(([lon, lat]) =>
Math.hypot((lon - centroid.lon) * lonScale, (lat - centroid.lat) * 111320)));
const distance = radius * 1.5 + 12;
const directions = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
for (let index = 0; index < 8; index += 1) {
const [dx, dy] = directions[index];
assert.ok(Math.abs(probes[index][0] - (centroid.lon + dx * distance / lonScale)) < 1e-10);
assert.ok(Math.abs(probes[index][1] - (centroid.lat + dy * distance / 111320)) < 1e-10);
assert.ok(Math.abs(probes[index + 8][0] - probes[index][0] - 0.01) < 1e-10);
assert.ok(Math.abs(probes[index + 8][1] - probes[index][1]) < 1e-10);
}
assert.equal(new Set(probes.slice(0, 8).map((point) => point.join(','))).size, 8);
const fills = dataSources[0].entities.values.filter((entity) => entity.polygon?.fill?.getValue() !== false && entity.polygon);
assert.equal(fills.length, 2);
assert.ok(Math.abs(fills[0].polygon.height.getValue() - 57) < 1e-6, 'four accepted samples select floor(4 * 0.3), height 60 minus 3');
assert.ok(Math.abs(fills[1].polygon.height.getValue() - 77) < 1e-6, 'all unavailable probes retain anchor minus building height fallback');
const afterCompletion = probes.length;
frames.emit();
assert.equal(probes.length, afterCompletion, 'completion releases the sampler');
});
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
let probeCount = 0;
viewer.scene.clampToHeight = (position) => { probeCount += 1; return position; };
const cancelled = buildingAnnotation('cancelled-building');
const cancelledCompletion = renderer.add(cancelled, { pendingVisible: false });
const savedCancelled = frames.snapshot();
renderer.remove(cancelled);
assert.equal((await cancelledCompletion).status, 'cancelled');
for (const listener of savedCancelled) listener();
assert.equal(probeCount, 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 });
frames.emit();
assert.equal(probeCount, 1);
const savedTimeout = frames.snapshot();
// No further renderer frames arrive: both enqueue deadlines must still fire,
// including the tail task that has never had a chance to sample.
t.mock.timers.tick(2000);
assert.equal((await headCompletion).status, 'timeout');
const tailResult = await tailCompletion;
assert.equal(tailResult.status, 'timeout');
assert.equal(tailResult.timing.probes, 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);
const pending = buildingAnnotation('destroyed-building');
const destruction = renderer.add(pending, { pendingVisible: false });
const savedDestroy = frames.snapshot();
renderer.destroy();
assert.equal((await destruction).status, 'cancelled');
for (const listener of savedDestroy) listener();
t.mock.timers.tick(2000);
assert.equal(probeCount, 1);
assert.equal(dataSources.length, 0, 'destroy removes the owned data source');
assert.equal(frames.size, 0, 'destroy removes frame listeners');
});
test('a timed-out building upgrade keeps its existing pending screen group', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const anno = { ...buildingAnnotation('pending-upgrade'), ring: null, label: 'Building' };
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
anno.ring = buildingAnnotation('geometry').ring;
const completion = renderer.update(anno);
t.mock.timers.tick(2000);
assert.equal((await completion).status, 'timeout');
const after = findAnnotationGroup(globalThis.document);
assert.equal(after.group, before.group);
assert.ok(after.group.querySelector('.gev-anno-ring'), 'the honest pending point is not converted');
assert.equal(dataSources[0].entities.values.length, 0);
frames.emit();
assert.equal(dataSources[0].entities.values.length, 0);
renderer.destroy();
});
test('an asynchronous caption-conversion throw rolls back multipart world geometry and remains removable', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const anno = { ...buildingAnnotation('caption-failure'), ring: null, label: 'Building' };
renderer.add(anno);
const { group, svg } = findAnnotationGroup(globalThis.document);
const dot = group.querySelector('.gev-anno-dot');
anno.ring = buildingAnnotation('geometry').ring;
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);
// 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');
const originalSetAttribute = dot.setAttribute.bind(dot);
const failure = new Error('caption conversion failed');
dot.setAttribute = (name, value) => {
if (name === 'r') throw failure;
return originalSetAttribute(name, value);
};
// Also fail screen teardown once: the routing entry must survive this second
// failure so explicit cleanup can still reach the partial screen state.
const workingSetTimeout = globalThis.window.setTimeout;
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
const rejected = assert.rejects(completion, (error) => error === failure);
await rejected;
assert.equal(dataSources[0].entities.values.length, 0, 'all classification, cage and outline parts roll back');
assert.equal(annotationGroups(svg).length, 1, 'failed screen cleanup remains addressable');
globalThis.window.setTimeout = workingSetTimeout;
dot.setAttribute = originalSetAttribute;
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0);
renderer.remove(anno);
renderer.destroy();
});
+32 -6
View File
@@ -51,7 +51,11 @@ test('outlines sharing an outer ring but not their holes stay two marks', async
polygons: shapes[target],
footprintKind: 'area',
});
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
await engine.annotate({ type: 'area', target: 'a', label: 'A' });
await engine.annotate({ type: 'area', target: 'b', label: 'B' });
assert.equal(engine.list().length, 2, 'different holes: different marks');
@@ -65,11 +69,18 @@ test('a street highlight asks for the street outline', () => {
{ type: 'area', target: 'Congress Avenue', entityKind: 'street' },
);
assert.equal(
promoteStreetHighlight({ type: 'highlight', target: 'Lombard St, San Francisco' }).type,
promoteStreetHighlight({
type: 'highlight',
target: 'Lombard St, San Francisco',
}).type,
'area',
);
assert.equal(
promoteStreetHighlight({ type: 'highlight', target: 'the Alamo', entityKind: 'street' }).type,
promoteStreetHighlight({
type: 'highlight',
target: 'the Alamo',
entityKind: 'street',
}).type,
'area',
);
for (const spec of [
@@ -87,10 +98,25 @@ test('the engine resolves a bare street highlight with its footprint', async (t)
const seen = [];
const resolveTarget = async (args) => {
seen.push(args);
return { lon: -97.74, lat: 30.27, height: 0, label: args.target, source: 'fake', ring: null };
return {
lon: -97.74,
lat: 30.27,
height: 0,
label: args.target,
source: 'fake',
ring: null,
};
};
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
await engine.annotate({ type: 'highlight', target: 'Congress Avenue', label: 'Congress' });
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
await engine.annotate({
type: 'highlight',
target: 'Congress Avenue',
label: 'Congress',
});
assert.equal(seen[0].footprint, true);
assert.equal(seen[0].entityKind, 'street');
});
+39 -40
View File
@@ -4,6 +4,7 @@ import {
ringCentroid,
} from '../sources/featureGeometry.js';
import { isUnavailableCapability } from '../sources/capability.js';
import { polygonsContain } from '../data/adminBoundaries.js';
import {
BUILDING_MAX_DISTANCE_M,
OUTLINE_TILE_ZOOM,
@@ -79,30 +80,18 @@ export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
return null;
}
/** Ray-cast test for a `[lon, lat]` ring. */
function ringHolds(ring, lat, lon) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
return inside;
}
/**
* Parts with the one holding the anchor first. A boundary can be largest at
* sea (Tokyo's island municipalities), so size alone does not pick the part
* the ask is about; with no part holding the anchor, the order is kept.
* Parts with the one holding the anchor outside its holes first. A boundary
* can be largest at sea (Tokyo's island municipalities), so size alone does
* not pick the part the ask is about; with no part holding the anchor, the
* order is kept.
*/
export function anchorPartFirst(polygons, points) {
for (const { lat, lon } of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const at = polygons.findIndex(([outer]) => ringHolds(outer, lat, lon));
const at = polygons.findIndex((polygon) =>
polygonsContain([polygon], lat, lon),
);
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
if (at === 0) return polygons;
}
@@ -130,7 +119,6 @@ export function streetFootprint(street) {
kind: 'area',
heightM: null,
synthesized: false,
outlineSource: 'openfreemap',
};
}
@@ -182,11 +170,10 @@ export function createOutlineRungs({
if (!building) return null;
return {
ring: building.ring,
polygons: [building.rings],
polygons: building.polygons,
kind: 'building',
heightM: building.heightM,
synthesized: false,
outlineSource: 'openfreemap',
};
}
@@ -199,16 +186,16 @@ export function createOutlineRungs({
enclosingAreaFromTiles(decoded, {
lat: at.lat,
lon: at.lon,
name: outlineQueryText(ctx.target || ctx.matchName),
minAreaM2: GROUNDS_MIN_AREA_M2,
});
if (!area) return null;
if (!area || !polygonsContain(area.polygons, ctx.lat, ctx.lon)) return null;
return {
ring: area.ring,
polygons: [area.rings],
polygons: area.polygons,
kind: 'area',
heightM: null,
synthesized: false,
outlineSource: 'openfreemap',
};
}
@@ -224,14 +211,15 @@ export function createOutlineRungs({
{ query, kind, lat: bias.lat, lon: bias.lon },
{ signal: ctx.signal },
);
if (!outline?.polygons) return outline; // null | undefined | unavailable | rateLimited
if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited
// Named outlines must retain the resolved place's authority,
// including holes; a nearby same-name polygon is not the selected place.
if (!polygonsContain(outline.polygons, ctx.lat, ctx.lon)) return null;
const polygons = anchorPartFirst(outline.polygons, [
{ lat: ctx.lat, lon: ctx.lon },
outline.center || {},
]);
const ring = polygons[0][0];
// A grounds ask answered by a building-sized polygon is the dome, not the
// grounds: report no grounds outline rather than the wrong shape.
// A grounds ask answered by a building-sized polygon found the named
// building, not its grounds: keep its position to find what encloses it.
if (ctx.groundsLike && ringAreaM2(ring) < GROUNDS_MIN_AREA_M2)
@@ -245,7 +233,6 @@ export function createOutlineRungs({
: 'area',
heightM: null,
synthesized: false,
outlineSource: 'nominatim',
outlineName: outline.name,
};
}
@@ -263,28 +250,32 @@ export function createOutlineRungs({
// A cancelled ask (board cleared, superseded) stops between rungs, so it
// never spends a Nominatim request.
const alive = () => ctx.signal?.throwIfAborted();
const accept = (fp, asScope = scope) => {
const accept = (fp, asScope = scope, provider) => {
alive();
if (!fp) return null;
const patch = finish(fp, asScope);
if (patch?.ring) {
ctx.credit?.(fp.outlineSource === 'openfreemap' ? 'tiles' : 'osm');
ctx.credit?.(provider === 'openfreemap' ? 'tiles' : 'osm');
// One line per answered outline, like the resolver's own trace.
console.log(
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${fp.outlineSource}` +
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${provider}` +
` (${patch.footprintKind}, ${patch.polygons?.length || 1} part(s))`,
);
return { ...patch, outlineSource: fp.outlineSource };
return patch;
}
return null;
};
// Tiles first for streets and for a building pointed at rather than named.
if (scope === 'street') {
const street = accept(await streetRung(ctx), 'street');
const street = accept(await streetRung(ctx), 'street', 'openfreemap');
if (street) return street;
} else if (scope === 'building' && !named && !ctx.around) {
const building = accept(await buildingRung(ctx), 'building');
const building = accept(
await buildingRung(ctx),
'building',
'openfreemap',
);
if (building) return building;
}
@@ -304,27 +295,35 @@ export function createOutlineRungs({
if (found?.groundsSeed) {
groundsSeed = found.groundsSeed;
} else if (found?.ring) {
const patch = accept(found, ctx.groundsLike ? 'compound' : scope);
const patch = accept(
found,
ctx.groundsLike ? 'compound' : scope,
'nominatim',
);
if (patch) return patch;
} else if (found === undefined || found?.rateLimited) {
retryLater = { value: found };
}
}
// Grounds nobody named in a polygon: the open area enclosing the point.
// Grounds absent from the named lookup: a matching named tile area.
if (ctx.groundsLike && named) {
// Around the named building when Nominatim found it, else the anchor.
let grounds = groundsSeed
? accept(await groundsRung(ctx, groundsSeed), 'compound')
? accept(await groundsRung(ctx, groundsSeed), 'compound', 'openfreemap')
: null;
grounds ||= accept(await groundsRung(ctx), 'compound');
grounds ||= accept(await groundsRung(ctx), 'compound', 'openfreemap');
if (grounds) return grounds;
}
// A named building nobody outlined: the footprint at 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), 'building');
const building = accept(
await buildingRung(ctx),
'building',
'openfreemap',
);
if (building) return building;
}
// Keep the mark retryable only when nothing else could answer. A
+426 -73
View File
@@ -30,7 +30,10 @@ import {
const TILES = [6745, 6746].map((y) =>
decodeOpenFreeMapOutlineTile(
readFileSync(
new URL(`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`, import.meta.url),
new URL(
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
import.meta.url,
),
),
14,
3743,
@@ -79,12 +82,21 @@ function ladder(baseAnswer) {
},
finish: (fp, scope) => {
calls.finished.push(scope);
return { ring: fp.ring, polygons: fp.polygons, footprintKind: fp.kind, synthesized: false };
return {
ring: fp.ring,
polygons: fp.polygons,
footprintKind: fp.kind,
synthesized: false,
};
},
};
}
const UNAVAILABLE = { unavailable: true, retryable: false, code: 'OVERPASS_NOT_CONFIGURED' };
const UNAVAILABLE = {
unavailable: true,
retryable: false,
code: 'OVERPASS_NOT_CONFIGURED',
};
const ctx = (overrides) => ({
scope: 'auto',
target: 'X',
@@ -107,7 +119,10 @@ test('ask wording helpers', () => {
assert.equal(nominatimKindFor({ scope: 'city' }), 'city');
assert.equal(nominatimKindFor({ scope: 'state' }), 'admin');
assert.equal(nominatimKindFor({ scope: 'compound' }), 'landmark');
assert.equal(nominatimKindFor({ scope: 'building', groundsLike: true }), 'landmark');
assert.equal(
nominatimKindFor({ scope: 'building', groundsLike: true }),
'landmark',
);
assert.equal(nominatimKindFor({ scope: 'neighborhood' }), null);
assert.equal(nominatimKindFor({ scope: 'street' }), null);
assert.equal(nominatimKindFor({ scope: 'compound', pointLike: true }), null);
@@ -119,12 +134,17 @@ test('a street comes from tiles before the configured ladder, as merged ribbons'
const credits = [];
const run = ladder(UNAVAILABLE);
const result = await createOutlineRungs({ tiles, nominatim }).resolve(
ctx({ scope: 'street', target: 'Congress Avenue', matchName: 'Congress Avenue', credit: (k) => credits.push(k) }),
ctx({
scope: 'street',
target: 'Congress Avenue',
matchName: 'Congress Avenue',
credit: (k) => credits.push(k),
}),
run,
);
assert.ok(result.ring.length > 6);
assert.equal(result.polygons.length, 1);
assert.equal(result.outlineSource, 'openfreemap');
assert.equal('outlineSource' in result, false);
assert.equal(run.calls.base, 0, 'the ladder is not consulted');
assert.deepEqual(run.calls.finished, ['street']);
assert.deepEqual(credits, ['tiles']);
@@ -134,8 +154,15 @@ test('a street comes from tiles before the configured ladder, as merged ribbons'
test('a street the tiles do not name falls through to the ladder', async () => {
const run = ladder(UNAVAILABLE);
const result = await createOutlineRungs({ tiles: fixtureTiles(), nominatim: fakeNominatim(null) }).resolve(
ctx({ scope: 'street', target: 'Lombard Street', matchName: 'Lombard Street' }),
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim: fakeNominatim(null),
}).resolve(
ctx({
scope: 'street',
target: 'Lombard Street',
matchName: 'Lombard Street',
}),
run,
);
assert.equal(result, UNAVAILABLE);
@@ -145,24 +172,61 @@ test('a street the tiles do not name falls through to the ladder', async () => {
test('"circle this building" picks the tile building at the point first', async () => {
const nominatim = fakeNominatim(null);
const run = ladder(UNAVAILABLE);
const result = await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({ scope: 'building', target: 'this building', fromName: false }),
run,
);
assert.equal(result.footprintKind, 'building');
assert.equal(run.calls.base, 0);
assert.equal(nominatim.asks.length, 0);
const primary = [square(CAPITOL.lat, CAPITOL.lon, 0.0002)];
const other = [square(CAPITOL.lat + 0.001, CAPITOL.lon, 0.0002)];
const multipart = await createOutlineRungs({
tiles: {
fetchBounds: async () => ({
tiles: [
{
buildings: [
{
rings: primary,
featureParts: [primary, other],
clipped: false,
heightM: 20,
},
],
},
],
}),
},
nominatim,
}).resolve(
ctx({ scope: 'building', target: 'this building', fromName: false }),
ladder(UNAVAILABLE),
);
assert.deepEqual(
multipart.polygons,
[primary, other],
'the building rung retains all feature parts',
);
});
test('a named building asks Nominatim, then falls back to the tile building', async () => {
const nominatim = fakeNominatim(null);
const result = await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({ scope: 'building', target: 'Texas State Capitol' }),
ladder(UNAVAILABLE),
);
assert.deepEqual(nominatim.asks.map((a) => a.kind), ['building']);
assert.deepEqual(
nominatim.asks.map((a) => a.kind),
['building'],
);
assert.equal(result.footprintKind, 'building');
assert.equal(result.outlineSource, 'openfreemap');
});
test('a city with no configured Overpass is outlined by Nominatim', async () => {
@@ -170,43 +234,155 @@ test('a city with no configured Overpass is outlined by Nominatim', async () =>
const nominatim = fakeNominatim({ polygons: [[ring]], class: 'boundary' });
const credits = [];
const run = ladder(UNAVAILABLE);
const result = await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(
ctx({ scope: 'city', target: 'Toulouse, France', matchName: 'Toulouse', credit: (k) => credits.push(k) }),
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({
scope: 'city',
target: 'Toulouse, France',
matchName: 'Toulouse',
lat: 43.6,
lon: 1.44,
credit: (k) => credits.push(k),
}),
run,
);
assert.deepEqual(result.ring, ring);
assert.equal(result.outlineSource, 'nominatim');
assert.deepEqual(nominatim.asks[0], { query: 'Toulouse', kind: 'city', lat: CAPITOL.lat, lon: CAPITOL.lon });
assert.deepEqual(run.calls.finished, ['city'], 'scope caps and drift bound still apply');
assert.deepEqual(nominatim.asks[0], {
query: 'Toulouse',
kind: 'city',
lat: CAPITOL.lat,
lon: CAPITOL.lon,
});
assert.deepEqual(
run.calls.finished,
['city'],
'scope caps and drift bound still apply',
);
assert.deepEqual(credits, ['osm']);
});
test('an empty Nominatim polygon list falls back without throwing', async () => {
const nominatim = fakeNominatim({ polygons: [], class: 'boundary' });
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({ scope: 'city', target: 'Nowhere', matchName: 'Nowhere' }),
ladder(UNAVAILABLE),
);
assert.equal(result, UNAVAILABLE);
});
test('named outlines must contain the resolved anchor outside holes', async () => {
const anchor = { lat: 0, lon: 0 };
for (const context of [
{ scope: 'city' },
{ scope: 'state' },
{ scope: 'building' },
{ scope: 'compound' },
{ scope: 'building', groundsLike: true },
]) {
for (const polygons of [
[[square(0, 0.04, 0.01)]],
[[square(0, 0, 0.05), square(0, 0, 0.01)]],
]) {
const nominatim = fakeNominatim({ polygons, class: 'boundary' });
const run = ladder(UNAVAILABLE);
const credits = [];
const result = await createOutlineRungs({
tiles: { fetchBounds: async () => ({ tiles: [] }) },
nominatim,
}).resolve(
ctx({
...anchor,
...context,
target: 'Gateville',
matchName: 'Gateville',
view: { lat: 0, lon: 0.04, radiusKm: 1 },
credit: (kind) => credits.push(kind),
}),
run,
);
assert.equal(
result,
UNAVAILABLE,
`${JSON.stringify(context)}: reject geometry outside the anchor`,
);
assert.equal(nominatim.asks.length, 1);
assert.deepEqual(
run.calls.finished,
[],
'rejected boundary never reaches installation',
);
assert.deepEqual(
credits,
[],
'rejected boundary earns no outline credit',
);
}
}
const ring = square(0, 0, 0.01);
const valid = await createOutlineRungs({
tiles: { fetchBounds: async () => ({ tiles: [] }) },
nominatim: fakeNominatim({ polygons: [[ring]], class: 'building' }),
}).resolve(
ctx({ ...anchor, scope: 'building', target: 'Gateville' }),
ladder(UNAVAILABLE),
);
assert.deepEqual(
valid.ring,
ring,
'the containing named building remains valid',
);
});
test('a multi-part city leads with the part holding the anchor, not the largest', async () => {
const sea = square(33.5, 139.5, 0.8); // larger, far from the anchor
const outer = square(CAPITOL.lat, CAPITOL.lon, 0.4);
const hole = square(CAPITOL.lat, CAPITOL.lon, 0.3);
const land = square(CAPITOL.lat, CAPITOL.lon, 0.2);
const nominatim = fakeNominatim({ polygons: [[sea], [land]], class: 'boundary' });
const result = await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(
const nominatim = fakeNominatim({
polygons: [[sea], [outer, hole], [land]],
class: 'boundary',
});
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({ scope: 'city', target: 'Tokyo', matchName: 'Tokyo' }),
ladder(UNAVAILABLE),
);
assert.deepEqual(result.ring, land, 'the main ring (centroid, scope cap) is the anchored part');
assert.equal(result.polygons.length, 2, 'every part is still drawn');
assert.deepEqual(
result.ring,
land,
'the main ring (centroid, scope cap) is the anchored part',
);
assert.deepEqual(
result.polygons,
[[land], [sea], [outer, hole]],
'every part and hole is retained',
);
});
test('a real outline from the ladder (bundled or operator Overpass) is kept', async () => {
const nominatim = fakeNominatim(null);
const outline = { ring: square(0, 0, 1), synthesized: false };
const result = await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(
ctx({ scope: 'city', target: 'Austin' }),
ladder(outline),
);
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(ctx({ scope: 'city', target: 'Austin' }), ladder(outline));
assert.equal(result, outline);
assert.equal(nominatim.asks.length, 0);
});
test('a transient ladder failure stays retryable without spending a lookup', async () => {
const nominatim = fakeNominatim(null);
const result = await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({ scope: 'compound', target: 'Zilker Park' }),
ladder(undefined),
);
@@ -215,39 +391,123 @@ test('a transient ladder failure stays retryable without spending a lookup', asy
});
test('no Nominatim outline leaves the honest point; busy stays retryable', async () => {
const rungs = (answer) => createOutlineRungs({ tiles: fixtureTiles(), nominatim: fakeNominatim(answer) });
assert.equal(await rungs(null).resolve(ctx({ scope: 'compound', target: 'Nowhere Park' }), ladder(UNAVAILABLE)), UNAVAILABLE);
const rungs = (answer) =>
createOutlineRungs({
tiles: fixtureTiles(),
nominatim: fakeNominatim(answer),
});
assert.equal(
await rungs(null).resolve(
ctx({ scope: 'compound', target: 'Nowhere Park' }),
ladder(UNAVAILABLE),
),
UNAVAILABLE,
);
const busy = { rateLimited: true, retryAfterMs: 5000 };
assert.equal(await rungs(busy).resolve(ctx({ scope: 'compound', target: 'Zilker Park' }), ladder(UNAVAILABLE)), busy);
assert.equal(
await rungs(busy).resolve(
ctx({ scope: 'compound', target: 'Zilker Park' }),
ladder(UNAVAILABLE),
),
busy,
);
// After an operator's definitive miss, a busy Nominatim does not invite retries.
assert.equal(await rungs(busy).resolve(ctx({ scope: 'compound', target: 'Zilker Park' }), ladder(null)), null);
assert.equal(
await rungs(busy).resolve(
ctx({ scope: 'compound', target: 'Zilker Park' }),
ladder(null),
),
null,
);
});
test('grounds: a building-sized answer is not the grounds; a real enclosure is', async () => {
const dome = { polygons: [[square(30.2747, -97.7403, 0.0003)]], class: 'office' };
const grounds = { polygons: [[square(30.2747, -97.7403, 0.002)]], class: 'leisure' };
// The dome is refused; the open area enclosing the point stands in.
const small = await createOutlineRungs({ tiles: fixtureTiles(), nominatim: fakeNominatim(dome) }).resolve(
ctx({ scope: 'building', target: 'Texas Capitol grounds', groundsLike: true }),
const dome = {
polygons: [[square(30.2747, -97.7403, 0.0003)]],
class: 'office',
};
const grounds = {
polygons: [[square(30.2747, -97.7403, 0.002)]],
class: 'leisure',
};
// The dome is refused; an unlabeled enclosure cannot stand in as exact grounds.
const small = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim: fakeNominatim(dome),
}).resolve(
ctx({
scope: 'building',
target: 'Texas Capitol grounds',
groundsLike: true,
}),
ladder(UNAVAILABLE),
);
assert.equal(small.outlineSource, 'openfreemap');
assert.equal(small.footprintKind, 'area');
assert.equal(small, UNAVAILABLE);
const namedRing = square(CAPITOL.lat, CAPITOL.lon, 0.002);
const named = await createOutlineRungs({
tiles: {
fetchBounds: async () => ({
tiles: [
{
areas: [
{
name: 'Texas Capitol',
rings: [namedRing],
class: 'park',
clipped: false,
},
],
},
],
}),
},
nominatim: fakeNominatim(dome),
}).resolve(
ctx({
scope: 'building',
target: 'Texas Capitol grounds',
groundsLike: true,
}),
ladder(UNAVAILABLE),
);
assert.equal(
named.footprintKind,
'area',
'genuine matching named tile grounds remain supported',
);
assert.deepEqual(named.polygons, [[namedRing]]);
// Without enclosing tile cover, the honest point remains.
const bare = await createOutlineRungs({
tiles: { fetchBounds: async () => ({ tiles: [] }) },
nominatim: fakeNominatim(dome),
}).resolve(ctx({ scope: 'building', target: 'Texas Capitol grounds', groundsLike: true }), ladder(UNAVAILABLE));
}).resolve(
ctx({
scope: 'building',
target: 'Texas Capitol grounds',
groundsLike: true,
}),
ladder(UNAVAILABLE),
);
assert.equal(bare, UNAVAILABLE);
const nominatim = fakeNominatim(grounds);
const run = ladder(UNAVAILABLE);
const big = await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(
ctx({ scope: 'building', target: 'the Texas Capitol grounds', groundsLike: true }),
const big = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({
scope: 'building',
target: 'the Texas Capitol grounds',
groundsLike: true,
}),
run,
);
assert.equal(big.footprintKind, 'area');
assert.deepEqual(run.calls.finished, ['compound']);
assert.deepEqual(nominatim.asks.map((a) => [a.query, a.kind]), [['Texas Capitol', 'landmark']]);
assert.deepEqual(
nominatim.asks.map((a) => [a.query, a.kind]),
[['Texas Capitol', 'landmark']],
);
});
test('point-like, around and neighbourhood asks never reach Nominatim', async () => {
@@ -257,7 +517,10 @@ test('point-like, around and neighbourhood asks never reach Nominatim', async ()
{ scope: 'neighborhood', target: 'Mission District' },
]) {
const nominatim = fakeNominatim(null);
await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(ctx(extra), ladder(UNAVAILABLE));
await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(
ctx(extra),
ladder(UNAVAILABLE),
);
assert.equal(nominatim.asks.length, 0, JSON.stringify(extra));
}
});
@@ -267,14 +530,19 @@ test('point-like, around and neighbourhood asks never reach Nominatim', async ()
function routes(...plugins) {
const found = new Map();
for (const plugin of plugins)
plugin.configureServer({ middlewares: { use: (path, handler) => found.set(path, handler) } });
plugin.configureServer({
middlewares: { use: (path, handler) => found.set(path, handler) },
});
return found;
}
async function call(handlers, url, init = {}) {
const parsed = new URL(url, 'http://localhost');
const mount = [...handlers.keys()]
.filter((path) => parsed.pathname === path || parsed.pathname.startsWith(`${path}/`))
.filter(
(path) =>
parsed.pathname === path || parsed.pathname.startsWith(`${path}/`),
)
.sort((a, b) => b.length - a.length)[0];
if (!mount) return new Response('{}', { status: 404 });
const req = Readable.from(init.body ? [Buffer.from(init.body)] : []);
@@ -309,22 +577,72 @@ async function call(handlers, url, init = {}) {
const NOMINATIM_ROWS = {
Toulouse: [
{ category: 'place', type: 'city', name: 'Toulouse', lat: '43.6', lon: '1.44', geojson: { type: 'Point', coordinates: [1.44, 43.6] } },
{ category: 'boundary', type: 'administrative', addresstype: 'city', name: 'Toulouse', lat: '43.6', lon: '1.44', osm_type: 'relation', osm_id: 35738, geojson: { type: 'Polygon', coordinates: [square(43.6, 1.44, 0.05)] } },
{
category: 'place',
type: 'city',
name: 'Toulouse',
lat: '43.6',
lon: '1.44',
geojson: { type: 'Point', coordinates: [1.44, 43.6] },
},
{
category: 'boundary',
type: 'administrative',
addresstype: 'city',
name: 'Toulouse',
lat: '43.6',
lon: '1.44',
osm_type: 'relation',
osm_id: 35738,
geojson: { type: 'Polygon', coordinates: [square(43.6, 1.44, 0.05)] },
},
],
'Central Park': [
{ category: 'leisure', type: 'park', name: 'Central Park', lat: '40.78', lon: '-73.965', osm_type: 'relation', osm_id: 8, geojson: { type: 'Polygon', coordinates: [square(40.78, -73.965, 0.01)] } },
{
category: 'leisure',
type: 'park',
name: 'Central Park',
lat: '40.78',
lon: '-73.965',
osm_type: 'relation',
osm_id: 8,
geojson: { type: 'Polygon', coordinates: [square(40.78, -73.965, 0.01)] },
},
],
};
const PLACES = {
Toulouse: { lat: 43.6, lng: 1.44, name: 'Toulouse', label: 'Toulouse, France', types: ['locality', 'political'] },
'Central Park': { lat: 40.78, lng: -73.965, name: 'Central Park', label: 'Central Park, New York', types: ['park'] },
'Congress Avenue': { lat: 30.268, lng: -97.7427, name: 'Congress Avenue', label: 'Congress Ave, Austin', types: ['route'] },
Toulouse: {
lat: 43.6,
lng: 1.44,
name: 'Toulouse',
label: 'Toulouse, France',
types: ['locality', 'political'],
},
'Central Park': {
lat: 40.78,
lng: -73.965,
name: 'Central Park',
label: 'Central Park, New York',
types: ['park'],
},
'Congress Avenue': {
lat: 30.268,
lng: -97.7427,
name: 'Congress Avenue',
label: 'Congress Ave, Austin',
types: ['route'],
},
};
const viewerAt = (lat, lon, height = 4000) => ({
camera: { positionCartographic: { latitude: (lat * Math.PI) / 180, longitude: (lon * Math.PI) / 180, height } },
camera: {
positionCartographic: {
latitude: (lat * Math.PI) / 180,
longitude: (lon * Math.PI) / 180,
height,
},
},
});
test('default settings: outline, street and building asks send nothing to Overpass', async (t) => {
@@ -334,13 +652,19 @@ test('default settings: outline, street and building asks send nothing to Overpa
if (prior !== undefined) process.env.OVERPASS_UPSTREAMS = prior;
});
// Any request that escapes the injected transports fails the test.
t.mock.method(globalThis, 'fetch', async (url) => assert.fail(`unexpected network request ${url}`));
t.mock.method(globalThis, 'fetch', async (url) =>
assert.fail(`unexpected network request ${url}`),
);
const upstream = [];
const stateDir = mkdtempSync(path.join(os.tmpdir(), 'gev-outline-rungs-'));
t.after(() => rmSync(stateDir, { recursive: true, force: true }));
const gate = createNominatimGate({
settings: { endpoint: 'https://nominatim.openstreetmap.org/search', isPublic: true, dailyCap: 50 },
settings: {
endpoint: 'https://nominatim.openstreetmap.org/search',
isPublic: true,
dailyCap: 50,
},
fetchImpl: async (url) => {
upstream.push(String(url));
const q = new URL(url).searchParams.get('q');
@@ -363,21 +687,32 @@ test('default settings: outline, street and building asks send nothing to Overpa
nominatim: createNominatimOutlineClient({ fetchImpl: transport }),
}),
});
const placeSearch = { geocode: async (q) => ({ place: PLACES[q] || null, answered: true }) };
const placeSearch = {
geocode: async (q) => ({ place: PLACES[q] || null, answered: true }),
};
const resolve = async (viewer, args) => {
const result = await resolver.resolveAnnotationTarget({ viewer, placeSearch, footprint: true, deferFootprint: true, ...args });
const result = await resolver.resolveAnnotationTarget({
viewer,
placeSearch,
footprint: true,
deferFootprint: true,
...args,
});
return { result, outline: await result.resolveOutline() };
};
const paris = await resolve(viewerAt(43.6, 1.44, 40_000), { target: 'Toulouse' });
const paris = await resolve(viewerAt(43.6, 1.44, 40_000), {
target: 'Toulouse',
});
assert.ok(paris.outline?.ring?.length >= 4, 'city outlined');
assert.equal(paris.outline.outlineSource, 'nominatim');
const park = await resolve(viewerAt(40.78, -73.965), { target: 'Central Park' });
assert.equal(park.outline?.outlineSource, 'nominatim');
const park = await resolve(viewerAt(40.78, -73.965), {
target: 'Central Park',
});
const street = await resolve(viewerAt(30.27, -97.742), { target: 'Congress Avenue' });
assert.equal(street.outline?.outlineSource, 'openfreemap');
const street = await resolve(viewerAt(30.27, -97.742), {
target: 'Congress Avenue',
});
const building = await resolve(viewerAt(30.2747, -97.7403, 800), {
latitude: CAPITOL.lat,
@@ -386,37 +721,55 @@ 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?.outlineSource, 'openfreemap');
assert.ok(upstream.every((url) => url.startsWith('https://nominatim.openstreetmap.org/search?')));
assert.equal(upstream.length, 2, 'one upstream request per explicit ask that needed one');
assert.ok(
upstream.every((url) =>
url.startsWith('https://nominatim.openstreetmap.org/search?'),
),
);
assert.equal(
upstream.length,
2,
'one upstream request per explicit ask that needed one',
);
assert.ok(!upstream.some((url) => /overpass/i.test(url)));
// The only Overpass traffic is the local capability probe, answered locally.
assert.ok(
local.filter((line) => line.includes('/api/overpass')).every((line) => line === 'GET /api/overpass/status'),
local
.filter((line) => line.includes('/api/overpass'))
.every((line) => line === 'GET /api/overpass/status'),
local.join('\n'),
);
});
test('a cancelled ask stops between rungs and never reaches Nominatim', async () => {
const nominatim = fakeNominatim({ polygons: [[square(43.6, 1.44, 0.05)]], class: 'boundary' });
const nominatim = fakeNominatim({
polygons: [[square(43.6, 1.44, 0.05)]],
class: 'boundary',
});
const rungs = createOutlineRungs({ tiles: fixtureTiles(), nominatim });
// Cancelled while the configured ladder ran.
const controller = new AbortController();
await assert.rejects(
rungs.resolve(ctx({ scope: 'city', target: 'Toulouse', signal: controller.signal }), {
base: async () => {
controller.abort();
return UNAVAILABLE;
rungs.resolve(
ctx({ scope: 'city', target: 'Toulouse', signal: controller.signal }),
{
base: async () => {
controller.abort();
return UNAVAILABLE;
},
finish: (fp) => fp,
},
finish: (fp) => fp,
}),
),
{ name: 'AbortError' },
);
// Cancelled before it started: not even the ladder runs.
const run = ladder(UNAVAILABLE);
await assert.rejects(
rungs.resolve(ctx({ scope: 'city', target: 'Toulouse', signal: AbortSignal.abort() }), run),
rungs.resolve(
ctx({ scope: 'city', target: 'Toulouse', signal: AbortSignal.abort() }),
run,
),
{ name: 'AbortError' },
);
assert.equal(run.calls.base, 0);
+39 -10
View File
@@ -22,14 +22,14 @@ const TILES = [6745, 6746].map((y) =>
const UNAVAILABLE = { unavailable: true, retryable: false };
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
function setup(answer) {
function setup(answer, tiles = TILES) {
const tileCalls = [];
const asks = [];
const rungs = createOutlineRungs({
tiles: {
fetchBounds: async (box) => {
tileCalls.push(box);
return { tiles: TILES };
return { tiles };
},
},
nominatim: {
@@ -71,8 +71,8 @@ for (const [label, answer] of [
ctx({ target: 'the Texas Capitol grounds', groundsLike: true }),
ladder,
);
assert.equal(lawn.footprintKind, 'area');
assert.equal(lawn.outlineSource, 'openfreemap');
assert.ok(grounds.tileCalls.length > 0, 'grounds tiles were consulted');
assert.equal(lawn, answer, 'unnamed grounds preserve the retryable answer');
});
}
@@ -101,10 +101,9 @@ test('"around the Capitol grounds" is still a grounds ask', async () => {
ladder,
);
assert.equal(asks.length, 1);
assert.equal(lawn.outlineSource, 'openfreemap');
});
test('grounds are found around the named building when the anchor is off the lawn', async () => {
test('named grounds around a building seed still require the original anchor', async () => {
// A place-search anchor ~250 m from the dome, outside the Capitol lawn.
const offLawn = { lat: 30.27265, lon: -97.73925 };
const d = 0.0003;
@@ -122,7 +121,28 @@ test('grounds are found around the named building when the anchor is off the law
],
class: 'office',
};
const withoutSeed = setup(null);
const lawnRadius = 0.001;
const namedTiles = [
{
areas: [
{
name: 'Texas Capitol',
class: 'park',
clipped: false,
rings: [
[
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
[CAPITOL.lon + lawnRadius, CAPITOL.lat - lawnRadius],
[CAPITOL.lon + lawnRadius, CAPITOL.lat + lawnRadius],
[CAPITOL.lon - lawnRadius, CAPITOL.lat + lawnRadius],
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
],
],
},
],
},
];
const withoutSeed = setup(null, namedTiles);
assert.equal(
await withoutSeed.rungs.resolve(
ctx({
@@ -135,11 +155,20 @@ test('grounds are found around the named building when the anchor is off the law
UNAVAILABLE,
'the anchor alone is not inside the grounds',
);
const { rungs } = setup(dome);
const { rungs, tileCalls } = setup(dome, namedTiles);
const lawn = await rungs.resolve(
ctx({ ...offLawn, target: 'the Texas Capitol grounds', groundsLike: true }),
ladder,
);
assert.equal(lawn?.outlineSource, 'openfreemap');
assert.equal(lawn.footprintKind, 'area');
assert.ok(tileCalls.length > 0, 'the matching named enclosure was consulted');
assert.deepEqual(
tileCalls,
withoutSeed.tileCalls,
'a rejected polygon cannot add a shifted grounds-seed lookup',
);
assert.equal(
lawn,
UNAVAILABLE,
'a building seed cannot move the original anchor into the named grounds',
);
});
+115 -2
View File
@@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
import { polygonsContain } from '../data/adminBoundaries.js';
function viewerAt(lat, lon, height = 20_000) {
return {
@@ -169,6 +170,25 @@ test('a geocoded city or neighborhood upgrades to the bundled outline', async ()
name: 'Austin',
types: ['locality', 'political'],
});
const controller = new AbortController();
const credits = [];
const cancelled = await city.resolve(
'Austin',
{
...viewerAt(39, -100, 5_000_000),
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
},
{ signal: controller.signal },
);
const pending = cancelled.resolveOutline();
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(
credits,
[],
'a cancelled bundled continuation cannot mutate viewer credits',
);
// Seen from far away: the view does not settle the name, the geocoder does.
const resolved = await city.resolve('Austin', viewerAt(39, -100, 5_000_000));
assert.equal(resolved.source, 'geocode');
@@ -271,10 +291,56 @@ const square = (lat, lon, d) => [
[lon - d, lat - d],
];
test('a courtyard outline with no filled candidate anchor remains a point', async () => {
const lat = 0.0005;
const lon = 0.0005;
const ring = square(lat, lon, 0.0005);
const hole = square(lat, lon, 0.0002);
const polygons = [[ring, hole]];
const { resolve } = harness(null, {
outlineRungs: rungsAnswering({
ring,
polygons,
kind: 'building',
heightM: 20,
}),
});
const options = { latitude: lat, longitude: lon, entityKind: 'building' };
const pending = await resolve(
'this building',
viewerAt(lat, lon, 1000),
options,
);
const outline = await pending.resolveOutline();
assert.equal(
outline,
null,
'neither the mean nor the courtyard point is filled',
);
const inline = await resolve('this building', viewerAt(lat, lon, 1000), {
...options,
deferFootprint: false,
});
assert.equal(inline.ring, null);
assert.deepEqual(
[inline.lat, inline.lon],
[lat, lon],
'keep the honest point',
);
for (const resolved of [outline, inline]) {
if (resolved?.polygons)
assert.ok(polygonsContain(resolved.polygons, resolved.lat, resolved.lon));
}
});
test('a coarse bundled admin-1 city waits for a finer outline', async () => {
const overParis = viewerAt(48.8566, 2.3522, 40_000);
const ring = square(48.8566, 2.3522, 0.05);
const rungs = rungsAnswering({ ring, kind: 'area', heightM: null });
// The outer-ring vertex mean is in the hole; the resolved anchor is in
// the shell. Completion must not move that valid point into the hole.
const ring = square(48.8566, 2.3822, 0.04);
const hole = square(48.8566, 2.3822, 0.02);
const polygons = [[ring, hole]];
const rungs = rungsAnswering({ ring, polygons, kind: 'area', heightM: null });
const fine = harness(PARIS_PLACE, { outlineRungs: rungs });
const resolved = await fine.resolve('Paris', overParis);
assert.equal(
@@ -284,7 +350,17 @@ test('a coarse bundled admin-1 city waits for a finer outline', async () => {
);
const outline = await resolved.resolveOutline();
assert.deepEqual(outline.ring, ring, 'the finer outline wins');
assert.deepEqual(outline.polygons, polygons);
assert.equal(outline.lat, PARIS_PLACE.lat);
assert.equal(outline.lon, PARIS_PLACE.lng);
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
assert.deepEqual(rungs.asked, [{ scope: 'city', target: 'Paris' }]);
const inline = await fine.resolve('Paris', overParis, {
deferFootprint: false,
});
assert.deepEqual(inline.polygons, polygons);
assert.equal(inline.lat, outline.lat);
assert.equal(inline.lon, outline.lon);
// Nothing finer (Nominatim off, busy or capped): the bundled shape, not a point.
const coarse = harness(PARIS_PLACE, { outlineRungs: rungsAnswering(null) });
@@ -299,6 +375,29 @@ test('a coarse bundled admin-1 city waits for a finer outline', async () => {
const direct = await offline.resolve('Paris', overParis);
assert.equal(direct.source, 'bundled');
assert.equal(direct.label, 'Paris');
const overDallas = viewerAt(32.7767, -96.797, 20_000);
assert.equal(
await offline.resolve('Paris', overDallas),
null,
'a missing geocode cannot select the distant coarse homonym',
);
assert.equal(
await coarse.resolve('Paris', overDallas),
null,
'a rejected foreign geocode cannot restore the same distant polygon',
);
const outsideParis = harness(
{ ...PARIS_PLACE, lat: 48.9, lng: 2.6 },
{ outlineRungs: rungsAnswering(null) },
);
const anchored = await outsideParis.resolve('Paris', overParis);
assert.equal(anchored.source, 'geocode');
assert.equal(
await anchored.resolveOutline(),
undefined,
'a nearby anchor outside Paris preserves the transient base, not its coarse polygon',
);
});
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
@@ -357,6 +456,20 @@ test('a campus ask reaches the campus, not the Census place of its town', async
).resolveOutline();
assert.deepEqual(compound.ring, campus);
assert.equal(kindRungs.asked[0].scope, 'compound');
const labelRungs = rungsAnswering({
ring: campus,
kind: 'area',
heightM: null,
});
const byLabel = harness(stanfordTown, { outlineRungs: labelRungs });
const labelled = await byLabel.resolve(
'Stanford',
viewerAt(37.43, -122.17, 8000),
{ labelHint: 'Stanford campus' },
);
assert.notEqual(labelled.source, 'bundled');
assert.deepEqual((await labelled.resolveOutline()).ring, campus);
assert.equal(labelRungs.asked[0].scope, 'compound');
// "Stanford" alone is the town (the Stanford CDP), and a town named after a
// college stays a town.
const { resolve } = harness(stanfordTown, {
+111 -2
View File
@@ -44,16 +44,123 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
for (const name of ['Gulf of Mexico', 'the Alps']) {
const region = await resolveRegionRingForQuery(name);
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
assert.equal(region.source, 'natural-earth');
assert.equal(region.error, undefined);
}
});
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const miss = geocoderFor(null);
assert.equal(
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
null,
);
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
const inside = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['natural_feature'],
});
const region = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
inside,
);
assert.equal(region?.source, 'natural-earth');
assert.equal(region.name, 'Cordillera Oriental');
assert.ok(region.ring.length >= 8);
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
});
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
for (const [name, lat, lng] of [
['Cordillera Oriental', 30.27, -97.74],
['Canadian Shield', 45.6336, -74.86493],
]) {
const geocoder = geocoderFor({
lat,
lng,
label: name,
name,
types: ['natural_feature'],
});
assert.equal(
await resolveRegionRingForQuery(name, undefined, geocoder),
null,
);
assert.deepEqual(geocoder.calls, [name]);
}
});
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas: async (point) => {
lookups.push(point);
return [];
},
}),
});
const geo = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['locality'],
});
const resolved = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
geo,
);
assert.equal(
resolved,
null,
'an unavailable city boundary is not a natural-region outline',
);
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
});
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const controller = new AbortController();
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
const later = new AbortController();
await assert.rejects(
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
async geocode() {
later.abort();
return { place: null };
},
}),
{ name: 'AbortError' },
);
});
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
let lookupStarted;
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas(_point, { signal }) {
lookupStarted = Date.now();
adminSignal = signal;
return new Promise((resolve) => {
release = resolve;
@@ -61,7 +168,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
},
}),
});
const started = Date.now();
const region = await resolveRegionRingForQuery(
'Atlantis',
undefined,
@@ -73,8 +179,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
ring: null,
error: 'region-timeout',
});
// Timed from the boundary lookup, so the bundled pack scans before it do not
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
assert.ok(
Date.now() - started < 1000,
Date.now() - lookupStarted < 2_000,
'returns at the budget, not the lookup',
);
// The lookup is left running so it can fill the boundary cache.
@@ -116,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
]) {
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
assert.equal(region?.name, name, query);
assert.equal(region.source, 'bundled-admin');
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
}
assert.deepEqual(geocoder.calls, [], 'no geocoding');
+92 -48
View File
@@ -46,7 +46,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Ms the analyst region lookup waits on geocode + admin boundary. */
export const REGION_FALLBACK_BUDGET_MS = 3_000;
const REGION_FALLBACK_BUDGET_MS = 3_000;
/**
* Names that end like a landmark rather than a district: "Central Park",
@@ -137,18 +137,6 @@ export function createAnnotationResolver({
cacheWrite(cache, key, null);
}
/** Wire an external AbortSignal to a local controller; returns a detach fn. */
function linkAbort(controller, externalSignal) {
if (!externalSignal) return () => {};
if (externalSignal.aborted) {
controller.abort();
return () => {};
}
const onAbort = () => controller.abort();
externalSignal.addEventListener('abort', onAbort, { once: true });
return () => externalSignal.removeEventListener('abort', onAbort);
}
/**
* Resolve a single annotation target to a normalized world anchor.
*
@@ -214,6 +202,16 @@ export function createAnnotationResolver({
// Earth's 11-vertex "Paris"): kept as the fallback while the finer rungs
// (Census, Nominatim) try first.
let coarseCityShape = null;
let bundledCenter = null;
const coarseCityHolds = (point) =>
coarseCityShape &&
point &&
polygonsContainPoint(coarseCityShape.polygons, point.lat, point.lon);
const compoundAsk =
entityKind === 'compound' ||
isCampusAsk(target) ||
isGroundsLikeQuery(target) ||
isGroundsLikeQuery(labelHint);
const askNamesAdmin = Boolean(adminScopeFromAsk(target, entityKind));
// Instrumentation (logged once per target at the end): which sources were tried and what they returned.
const trace = {
@@ -232,33 +230,31 @@ export function createAnnotationResolver({
footprint &&
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
trace.query &&
!compoundAsk &&
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
) {
const center =
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
bundledCenter = center;
const admin = await findAdminArea(trace.query, { near: center }).catch(
() => null,
);
signal?.throwIfAborted();
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape = admin;
else if (admin) return bundledAdminTarget(viewer, admin, trace.query);
else if (admin)
return bundledAdminTarget(viewer, admin, trace.query, signal);
// Bundled US places and neighborhoods named at or near the view. A
// campus, grounds or monument ask ("Stanford" as a compound, "the
// Capitol grounds") wants that feature, not a district of its name,
// and "the area around X" is drawn by the ladder below.
if (
entityKind !== 'compound' &&
intent !== 'around_the_thing' &&
!isGroundsLikeQuery(trace.query) &&
!isMonumentLikeQuery(trace.query)
) {
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
const area = await bundledAreaNear(trace.query, center, {
entityKind,
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
});
signal?.throwIfAborted();
if (area) return bundledAdminTarget(viewer, area, trace.query);
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
}
}
@@ -388,8 +384,8 @@ export function createAnnotationResolver({
}
if (!Number.isFinite(lat) || !Number.isFinite(lon))
return coarseCityShape
? bundledAdminTarget(viewer, coarseCityShape, trace.query)
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
: null;
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
@@ -455,8 +451,8 @@ export function createAnnotationResolver({
`osmSnap=${trace.osmSnap} → FINAL source=rejected (proximity gate, ${drift.driftKm.toFixed(0)}km > ${drift.limitKm.toFixed(0)}km)`,
);
}
return coarseCityShape
? bundledAdminTarget(viewer, coarseCityShape, trace.query)
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
: null;
}
}
@@ -482,11 +478,6 @@ export function createAnnotationResolver({
// entity fact wants that compound even when the geocoder types the town
// or the state: never a Census place, neighborhood or admin boundary.
const typedScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
const compoundAsk =
entityKind === 'compound' ||
isCampusAsk(target) ||
isGroundsLikeQuery(target) ||
isGroundsLikeQuery(labelHint);
const baseScope =
compoundAsk &&
['country', 'state', 'county', 'city', 'neighborhood'].includes(
@@ -523,6 +514,7 @@ export function createAnnotationResolver({
* AFTER the anchor mark is drawn and upgrade the mark in place.
*/
const resolveBaseOutline = async () => {
signal?.throwIfAborted();
let scope = baseScope;
const isAdmin =
scope === 'country' ||
@@ -548,6 +540,7 @@ export function createAnnotationResolver({
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
() => null,
);
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
traceBundledOutline(ne.name, 'natural-earth');
@@ -606,9 +599,10 @@ export function createAnnotationResolver({
lon,
scope,
).catch(() => null);
signal?.throwIfAborted();
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape ||= admin;
else if (admin) return bundledAdminOutline(viewer, admin);
else if (admin) return bundledAdminOutline(viewer, admin, signal);
}
// A geocoded US city, town or CDP: the Census place that holds it.
if (scope === 'city') {
@@ -617,7 +611,8 @@ export function createAnnotationResolver({
lat,
lon,
).catch(() => null);
if (place) return bundledAdminOutline(viewer, place);
signal?.throwIfAborted();
if (place) return bundledAdminOutline(viewer, place, signal);
}
// Pure admin: only an admin boundary is correct — never fall back to a
// building/landuse (a city is never a single building).
@@ -628,6 +623,7 @@ export function createAnnotationResolver({
// resolve here instantly to a REAL boundary, sidestepping the slow/flaky live-Overpass
// path that times out and falls back to points (see docs/field-test-2-analysis.md).
const ext = await lookupNeighborhoodRing(lat, lon, matchName);
signal?.throwIfAborted();
if (ext) {
traceBundledOutline(ext.name || matchName, 'datasf');
fp = { ring: ext.ring, kind: 'area', heightM: null };
@@ -639,7 +635,8 @@ export function createAnnotationResolver({
lat,
lon,
).catch(() => null);
if (wof) return bundledAdminOutline(viewer, wof);
signal?.throwIfAborted();
if (wof) return bundledAdminOutline(viewer, wof, signal);
}
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
// returns a footprint, null (definitively no polygon), or undefined (transient
@@ -786,8 +783,18 @@ export function createAnnotationResolver({
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;
const centroid = ringCentroid(fp.ring);
let centroid = ringCentroid(fp.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)
) {
if (!polygonsContainPoint(fp.polygons, lat, lon)) return null;
centroid = { lat, lon };
}
// 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 (
@@ -833,10 +840,14 @@ export function createAnnotationResolver({
base: resolveBaseOutline,
finish: finishOutline,
});
signal?.throwIfAborted();
// Nothing finer answered (no polygon, Nominatim off, busy or capped):
// the coarse bundled shape beats a point.
if (coarseCityShape && !(result?.ring && !result.synthesized))
return bundledAdminOutline(viewer, coarseCityShape);
if (
coarseCityHolds({ lat, lon }) &&
!(result?.ring && !result.synthesized)
)
return bundledAdminOutline(viewer, coarseCityShape, signal);
return result;
};
@@ -1034,7 +1045,8 @@ export function createAnnotationResolver({
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
* centroid drift bound: the unit IS the asked scope.
*/
function bundledAdminOutline(viewer, admin) {
function bundledAdminOutline(viewer, admin, signal) {
signal?.throwIfAborted();
creditAdminSource(viewer, admin);
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
const { lat, lon } = admin.label;
@@ -1077,8 +1089,8 @@ export function createAnnotationResolver({
}
/** A complete resolved target for a bundled administrative unit (no outline pending). */
function bundledAdminTarget(viewer, admin, query) {
const outline = bundledAdminOutline(viewer, admin);
function bundledAdminTarget(viewer, admin, query, signal) {
const outline = bundledAdminOutline(viewer, admin, signal);
const [west, south, east, north] = admin.bbox;
console.log(
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
@@ -2283,8 +2295,8 @@ export function createAnnotationResolver({
/**
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
* a name-only entry point that reuses this module's boundary machinery
* without the annotation pipeline. Natural Earth pack first (offline,
* instant; largest-area match is correct for a global name-only ask), then
* without the annotation pipeline. Unique Natural Earth names resolve first
* (offline); duplicate names require a geocoded containment anchor, then
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
* Overpass, so repeat asks are instant). Returns null when the name doesn't
* resolve to a region-like boundary — the analyst engine reports that
@@ -2299,7 +2311,7 @@ export function createAnnotationResolver({
* @param {AbortSignal} [signal]
* @param {object} [placeSearch]
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
*/
async function resolveRegionRingForQuery(
name,
@@ -2316,16 +2328,24 @@ export function createAnnotationResolver({
const q = String(name || '').trim();
if (!q) return null;
const ne = await findNaturalRegion(q).catch(() => null);
if (ne?.polygons?.length) {
signal?.throwIfAborted();
if (ne?.candidates === 1 && ne.polygons?.length) {
// Largest ring carries the query scope; multi-ring regions (Andes) keep
// their main cordillera — good enough for containment counting.
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3) return { name: ne.name, ring };
if (ring?.length >= 3)
return { name: ne.name, ring, source: 'natural-earth' };
}
// Bundled states/provinces/counties by name (offline); the main part only,
// like the Natural Earth rung above.
const admin = await findAdminArea(q).catch(() => null);
if (admin) return { name: admin.name, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return {
name: admin.name,
ring: [...admin.ring],
source: 'bundled-admin',
};
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
if (!Number.isFinite(budgetMs)) return lookup;
let timer;
@@ -2346,8 +2366,26 @@ export function createAnnotationResolver({
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
() => null,
);
signal?.throwIfAborted();
if (!geo) return null;
const scope = scopeFromTypes(geo.types);
if (
scope === 'auto' ||
(scope === 'compound' && geo.types?.includes('natural_feature'))
) {
const natural = await lookupNaturalRegionOutline(
q,
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (natural)
return {
name: natural.name,
ring: natural.ring,
source: 'natural-earth',
};
}
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
if (scope === 'state' || scope === 'county' || scope === 'country') {
const admin = await findAdminAreaAt(
@@ -2356,7 +2394,9 @@ export function createAnnotationResolver({
geo.lon,
scope,
).catch(() => null);
if (admin) return { name: q, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
}
if (scope === 'city') {
const place = await findPlaceAreaAt(
@@ -2364,12 +2404,16 @@ export function createAnnotationResolver({
geo.lat,
geo.lon,
).catch(() => null);
if (place) return { name: q, ring: [...place.ring] };
signal?.throwIfAborted();
if (place)
return { name: q, ring: [...place.ring], source: 'bundled-admin' };
}
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
() => null,
);
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
signal?.throwIfAborted();
if (fp?.ring?.length >= 3)
return { name: q, ring: fp.ring, source: 'remote-boundary' };
return null;
}
+49 -18
View File
@@ -4,6 +4,12 @@ import {
getActiveTrackedReadoutId,
TRACKED_OVERLAY_SOURCE_ID,
} from '../data/trackedReadout.js';
import { t } from '../i18n/index.js';
/** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) {
return t('{label} · Detailed outline unavailable', { label: label || '' });
}
/**
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
@@ -151,9 +157,10 @@ export function createScreenAnnotationRenderer(
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
// Synthesized (buffered/approximate) areas render DASHED + fainter, with no
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
parts.poly = svgEl('polygon', {
parts.poly = svgEl('path', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
fill: c,
'fill-rule': 'evenodd',
stroke: c,
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
'stroke-width': '2.5',
@@ -163,7 +170,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.poly);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -181,7 +188,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.path);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -215,7 +222,7 @@ export function createScreenAnnotationRenderer(
});
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -259,7 +266,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.leader);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -322,7 +329,7 @@ export function createScreenAnnotationRenderer(
}
rec.anno = anno;
if (anno.outlineUnavailable && rec.parts.label) {
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
sizeCallout(rec.parts.label);
}
@@ -352,7 +359,7 @@ export function createScreenAnnotationRenderer(
// Area-type updates route through the hybrid proxy only.
}
function project(lon, lat) {
function project(lon, lat, allowOffscreen = false) {
// Anchor at the real surface height (cached + validated), so marks sit on
// the ground/building instead of at sea level. The settle-once cache keeps
// it stable (no per-frame jitter that earlier broke nearby projections).
@@ -376,7 +383,11 @@ export function createScreenAnnotationRenderer(
// Reject absurd off-screen projections (anchor not in view).
const w = scene.canvas.clientWidth || scene.canvas.width;
const h = scene.canvas.clientHeight || scene.canvas.height;
if (win.x < -w || win.x > 2 * w || win.y < -h || win.y > 2 * h) return null;
if (
!allowOffscreen &&
(win.x < -w || win.x > 2 * w || win.y < -h || win.y > 2 * h)
)
return null;
return { x: win.x, y: win.y };
}
@@ -481,20 +492,40 @@ export function createScreenAnnotationRenderer(
group.setAttribute('opacity', String(anno.alpha ?? 1));
if (anno.type === 'area' && parts.poly) {
const pts = [];
const paths = [];
let cx = 0;
let cy = 0;
let n = 0;
for (const [lon, lat] of anno.ring) {
const p = project(lon, lat);
if (!p) continue;
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
cx += p.x;
cy += p.y;
n++;
const polygons = anno.polygons?.length ? anno.polygons : [[anno.ring]];
for (const polygon of polygons) {
const projectedRings = polygon.map((ring) =>
ring.map(([lon, lat]) => project(lon, lat, polygon.length > 1)),
);
// Keep the historical visible-outer projection, but never fill over
// an unprojectable hole or hide another independently visible part.
const outer = projectedRings[0].filter(Boolean);
const holes = projectedRings.slice(1);
if (
outer.length < 3 ||
(holes.length && projectedRings[0].some((p) => !p)) ||
holes.some((ring) => ring.length < 3 || ring.some((p) => !p))
)
continue;
for (const projected of [outer, ...holes]) {
paths.push(
`M ${projected.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' L ')} Z`,
);
}
if (!n) {
for (const p of outer) {
cx += p.x;
cy += p.y;
n++;
}
}
}
if (pts.length >= 3) {
parts.poly.setAttribute('points', pts.join(' '));
if (paths.length) {
parts.poly.setAttribute('d', paths.join(' '));
group.style.display = '';
if (parts.label)
positionCallout(parts.label, cx / n, cy / n - 6, true);
+103 -13
View File
@@ -201,18 +201,22 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
renderer.destroy();
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
test("annotation fade consumes the actual tracked host paint rectangle after layout", (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.requestAnimationFrame = (callback) => {
callback();
return 1;
};
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
if (originalRequestAnimationFrame === undefined)
delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
@@ -221,7 +225,11 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const camera = {
positionWC,
directionWC,
positionCartographic: { height: 1000 },
};
const scene = {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
@@ -232,7 +240,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{
activeTrackedReadoutId: () => 'installations:test',
activeTrackedReadoutId: () => "installations:test",
overlayPaintRect(sourceId, entryId) {
paintRectCalls.push({ sourceId, entryId });
return { x: -10, y: -10, w: 120, h: 80 };
@@ -240,22 +248,104 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
},
);
renderer.add({
id: 'anno-overlap',
type: 'label',
color: 'primary',
label: 'OVERLAP',
id: "anno-overlap",
type: "label",
color: "primary",
label: "OVERLAP",
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
const { group } = findAnnotationGroup(globalThis.document);
assert.deepEqual(paintRectCalls.at(-1), {
sourceId: 'tracked',
entryId: 'installations:test',
sourceId: "tracked",
entryId: "installations:test",
});
assert.ok(
Number(group.getAttribute('opacity')) < 1,
'final annotation bbox fades against the complete painted card rectangle',
Number(group.getAttribute("opacity")) < 1,
"final annotation bbox fades against the complete painted card rectangle",
);
const outer = [
[0, 0],
[0.001, 0],
[0.001, 0.001],
[0, 0.001],
[0, 0],
];
const hole = [
[0.0002, 0.0002],
[0.0004, 0.0002],
[0.0004, 0.0004],
[0.0002, 0.0004],
[0.0002, 0.0002],
];
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
const multipart = {
id: "anno-multipart-screen",
label: "Multipart",
type: "area",
color: "primary",
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
ring: outer,
polygons: [[outer, hole], [island]],
synthesized: false,
};
renderer.add(multipart);
const area = globalThis.document.body.querySelector(".gev-anno-area");
assert.equal(area.tagName, "path");
assert.equal(area.getAttribute("fill-rule"), "evenodd");
assert.equal(
(area.getAttribute("d").match(/M /g) || []).length,
3,
"screen fill retains both parts and the hole",
);
multipart.polygons = [
[island.map(([lon, lat]) => [lon + 180, lat])],
[outer, hole],
];
renderer.sync();
assert.equal(
(area.getAttribute("d").match(/M /g) || []).length,
2,
"an island behind the globe cannot hide the visible part and its hole",
);
assert.notEqual(area.parentNode.style.display, "none");
assert.doesNotMatch(
area.parentNode
.querySelector(".gev-anno-callout")
.getAttribute("transform"),
/NaN/,
);
multipart.polygons = [[outer, hole]];
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, world) => {
const lon = Cesium.Math.toDegrees(
Cesium.Cartographic.fromCartesian(world).longitude,
);
return { x: lon > 0.0009 ? 2401 : 0, y: 0 };
};
renderer.sync();
assert.equal(
(area.getAttribute("d").match(/M /g) || []).length,
2,
"finite offscreen shell coordinates cannot hide a visible holed part",
);
assert.notEqual(area.parentNode.style.display, "none");
multipart.polygons = [[outer, hole], [island]];
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, world) => {
const lon = Cesium.Math.toDegrees(
Cesium.Cartographic.fromCartesian(world).longitude,
);
return Math.abs(lon - 0.0002) < 1e-7 ? undefined : { x: 0, y: 0 };
};
renderer.sync();
assert.equal(
(area.getAttribute("d").match(/M /g) || []).length,
1,
"an unprojectable hole omits its entire part, not the independently visible island",
);
assert.notEqual(area.parentNode.style.display, "none");
renderer.destroy();
});
+225 -76
View File
@@ -51,6 +51,133 @@ export function createWorldAnnotationRenderer(viewer) {
const dataSource = new Cesium.CustomDataSource('gev-annotations');
viewer.dataSources.add(dataSource);
const osmAnnotations = new Set();
const pendingGround = new Map();
const groundQueue = [];
let observingGround = false;
let destroyed = false;
function sampleBuilding(anno, options) {
pendingGround.get(anno)?.finish('cancelled');
return new Promise((resolve, reject) => {
const started = performance.now();
const task = {
anno,
points: groundProbePositions(anno.ring),
samples: [],
index: 0,
};
const timing = {
workMs: 0,
maxSliceMs: 0,
lastSliceMs: 0,
probes: 0,
deadlineMs: started + 2000,
};
task.current = () =>
!destroyed &&
!options.signal?.aborted &&
pendingGround.get(anno) === task &&
(options.isCurrent?.() ?? true);
task.finish = (status, error) => {
if (pendingGround.get(anno) !== task) return;
pendingGround.delete(anno);
groundQueue.splice(groundQueue.indexOf(task), 1);
clearTimeout(task.timer);
options.signal?.removeEventListener('abort', task.abort);
if (!groundQueue.length && observingGround) {
viewer.scene.postRender.removeEventListener(onGroundFrame);
observingGround = false;
}
timing.elapsedMs = performance.now() - started;
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);
}
} catch {
/* surface not ready: preserve the existing sample filter */
}
} else task.index = task.points.length;
if (performance.now() - started >= 2000) {
task.finish('timeout');
return;
}
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) {
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);
}
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);
task.timer = setTimeout(() => task.finish('timeout'), 2000);
options.signal?.addEventListener('abort', task.abort, { once: true });
if (!task.current()) return task.finish('cancelled');
if (!observingGround) {
viewer.scene.postRender.addEventListener(onGroundFrame);
observingGround = true;
}
try {
viewer.scene.requestRender?.();
} catch (error) {
task.finish('failed', error);
}
});
}
function onGroundFrame() {
// One building probe TOTAL, not one per mark. Other renderers have their own work.
const task = groundQueue[0];
try {
task?.step();
if (groundQueue.length) viewer.scene.requestRender?.();
} catch (error) {
for (const pending of [...groundQueue]) pending.finish('failed', error);
}
}
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
// material the route pipeline renders. The animated `time` uniform is read straight
@@ -81,7 +208,7 @@ export function createWorldAnnotationRenderer(viewer) {
}, false);
}
function add(anno) {
function add(anno, options = {}) {
const base = colorFor(anno);
// Published to the mark BEFORE anything is added, and mutated in place as
// each entity lands: a mid-add failure (bad geometry, lost context) must
@@ -90,20 +217,40 @@ export function createWorldAnnotationRenderer(viewer) {
const entities = [];
anno._entities = entities;
if (
anno.ring?.length >= 3 &&
anno.footprintKind === 'building' &&
!Object.hasOwn(options, 'sampledGroundHeight')
) {
return sampleBuilding(anno, options);
}
if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
const addBuildingEntity = (definition) => {
if (!(options.isCurrent?.() ?? true))
throw new Error('Building install cancelled');
const entity = dataSource.entities.add(definition);
if (!(options.isCurrent?.() ?? true)) {
dataSource.entities.remove(entity);
throw new Error('Building install cancelled');
}
return entity;
};
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
// extrusion is only 2.5D and misses domes; classification colors the
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const parts =
Array.isArray(anno.polygons) && anno.polygons.length
? anno.polygons
: [[anno.ring]];
const groundH =
sampleGroundOutside(viewer.scene, anno.ring) ??
options.sampledGroundHeight ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
@@ -112,55 +259,73 @@ export function createWorldAnnotationRenderer(viewer) {
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
},
}),
const fillMaterial = new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
const lineMaterial = new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
});
for (const [outer, ...holes] of parts) {
const buffered = bufferRing(outer, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(
addBuildingEntity({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
positions,
holes.map(
(hole) =>
new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
),
),
),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: fillMaterial,
},
}),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
addBuildingEntity({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
for (const ring of [outer, ...holes]) {
entities.push(
addBuildingEntity({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(
closedRing(ring).flat(),
),
width: 3,
material: lineMaterial,
clampToGround: true,
classificationType: CLASSIFY,
},
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
);
}
}
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park / state) → flat fill draped on
// the tiles + a glowing outline. Draping is right here: you can't extrude
@@ -318,6 +483,7 @@ export function createWorldAnnotationRenderer(viewer) {
}
function remove(anno) {
pendingGround.get(anno)?.finish('cancelled');
hideOsmCredit(viewer, anno);
osmAnnotations.delete(anno);
if (!anno?._entities) return;
@@ -336,6 +502,8 @@ export function createWorldAnnotationRenderer(viewer) {
}
function destroy() {
destroyed = true;
for (const task of [...pendingGround.values()]) task.finish('cancelled');
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
osmAnnotations.clear();
try {
@@ -458,17 +626,10 @@ function decimateRing(ring, n) {
}
/**
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
* the centroid would clamp onto the roof, so we probe several points beyond the
* footprint radius and take a low percentile (≈ ground). Returns null if the
* tiles under those points aren't loaded yet.
* The original eight outside-footprint probes. Keep their coordinates and order
* while scheduling the expensive scene picks across rendered frames.
*/
function sampleGroundOutside(scene, ring) {
if (
!scene?.clampToHeightSupported ||
typeof scene.clampToHeight !== 'function'
)
return null;
function groundProbePositions(ring) {
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) {
@@ -494,23 +655,11 @@ function sampleGroundOutside(scene, ring) {
[0.7, -0.7],
[-0.7, 0.7],
];
const samples = [];
for (const [dx, dy] of dirs) {
return dirs.map(([dx, dy]) => {
const lon = clon + (dx * out) / lonS;
const lat = clat + (dy * out) / latS;
try {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch {
/* tile not ready */
}
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length * 0.3)];
return [lon, lat];
});
}
/**