mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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:
commit
d4434aa6d7
1489 files changed
+196442
-12353
No files matched your search
@@ -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']) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user