mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(outlines): voice outlines without Overpass — bundled US places and neighborhoods, guarded Nominatim, OpenFreeMap streets and buildings
This commit is contained in:
1 parent
e7707d9a0f
commit
9559e7a282
333 files changed
+13203
-271
No files matched your search
@@ -270,7 +270,9 @@ export function createAnnotationEngine({
|
||||
results: [],
|
||||
error: 'destroyed',
|
||||
};
|
||||
const list = Array.isArray(requests) ? requests : [requests];
|
||||
const list = (Array.isArray(requests) ? requests : [requests]).map(
|
||||
promoteStreetHighlight,
|
||||
);
|
||||
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
|
||||
|
||||
const persist = opts.persist !== false;
|
||||
@@ -718,7 +720,7 @@ export function createAnnotationEngine({
|
||||
continue;
|
||||
if ((other.footprintKind || null) !== (anno.footprintKind || null))
|
||||
continue;
|
||||
if (!ringsEqual(other.ring, anno.ring)) continue;
|
||||
if (!outlinesEqual(other, anno)) continue;
|
||||
annotations.delete(other.id);
|
||||
renderer.remove(other);
|
||||
}
|
||||
@@ -802,7 +804,6 @@ export function createAnnotationEngine({
|
||||
// FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for
|
||||
// candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT
|
||||
// same-length rings/paths at the same anchor are never falsely merged.
|
||||
const ringSame = ringsEqual;
|
||||
const pathSame = (a, b) => {
|
||||
const la = a ? a.length : 0;
|
||||
const lb = b ? b.length : 0;
|
||||
@@ -836,7 +837,7 @@ export function createAnnotationEngine({
|
||||
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue;
|
||||
if ((ex.footprintKind || null) !== (anno.footprintKind || null))
|
||||
continue;
|
||||
if (!ringSame(ex.ring, anno.ring)) continue;
|
||||
if (!outlinesEqual(ex, anno)) continue;
|
||||
}
|
||||
} else if (anno.type === 'arrow') {
|
||||
if (!near(ex.to, anno.to)) continue;
|
||||
@@ -1310,6 +1311,49 @@ function ringsEqual(a, b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-outline equality: the main ring AND every part with its holes. Two
|
||||
* outlines sharing an outer ring but differing in parts or holes are two marks.
|
||||
*/
|
||||
function outlinesEqual(a, b) {
|
||||
if (!ringsEqual(a?.ring, b?.ring)) return false;
|
||||
const parts = (anno) =>
|
||||
Array.isArray(anno?.polygons) && anno.polygons.length
|
||||
? anno.polygons
|
||||
: [[anno?.ring || []]];
|
||||
const pa = parts(a);
|
||||
const pb = parts(b);
|
||||
if (pa.length !== pb.length) return false;
|
||||
for (let i = 0; i < pa.length; i++) {
|
||||
if (pa[i].length !== pb[i].length) return false;
|
||||
for (let j = 0; j < pa[i].length; j++)
|
||||
if (!ringsEqual(pa[i][j], pb[i][j])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const STREET_WORD_RE =
|
||||
/\b(?:street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|highway|hwy|parkway|pkwy|alley)\.?$/i;
|
||||
|
||||
/**
|
||||
* "Highlight Congress Avenue" asks for the street, not a dot. A highlight
|
||||
* whose entity is a street (by entityKind or by its name) becomes an area
|
||||
* ask, so it resolves the street outline like any other area, unless the
|
||||
* caller explicitly turned the footprint off.
|
||||
*/
|
||||
export function promoteStreetHighlight(spec) {
|
||||
if (!spec || typeof spec !== 'object' || spec.footprint === false)
|
||||
return spec;
|
||||
if (normalizeType(spec.type) !== 'highlight') return spec;
|
||||
if (spec.entityKind && spec.entityKind !== 'street') return spec;
|
||||
const target = String(spec.target || '').split(',')[0];
|
||||
const street =
|
||||
spec.entityKind === 'street' || STREET_WORD_RE.test(target.trim());
|
||||
return street && target.trim()
|
||||
? { ...spec, type: 'area', entityKind: 'street' }
|
||||
: spec;
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
/** The place name(s) a spec attempted to resolve (route waypoints, arrow endpoints, or a single target). */
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Multipart outline identity and street highlights in the annotation engine.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createAnnotationEngine,
|
||||
promoteStreetHighlight,
|
||||
} from './annotationEngine.js';
|
||||
|
||||
function installAnimationFrameStubs(t) {
|
||||
const raf = globalThis.requestAnimationFrame;
|
||||
const caf = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
if (raf === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = raf;
|
||||
if (caf === undefined) delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = caf;
|
||||
});
|
||||
}
|
||||
|
||||
const renderer = { add() {}, update() {}, remove() {}, sync() {} };
|
||||
const outer = [
|
||||
[0, 0],
|
||||
[0.01, 0],
|
||||
[0.01, 0.01],
|
||||
[0, 0.01],
|
||||
[0, 0],
|
||||
];
|
||||
const hole = (d) => [
|
||||
[0.004, 0.004],
|
||||
[0.004 + d, 0.004],
|
||||
[0.004 + d, 0.004 + d],
|
||||
[0.004, 0.004],
|
||||
];
|
||||
|
||||
test('outlines sharing an outer ring but not their holes stay two marks', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const shapes = {
|
||||
a: [[outer, hole(0.001)]],
|
||||
b: [[outer, hole(0.002)]],
|
||||
same: [[outer, hole(0.001)]],
|
||||
};
|
||||
const resolveTarget = async ({ target }) => ({
|
||||
lon: 0.005,
|
||||
lat: 0.005,
|
||||
height: 0,
|
||||
label: target,
|
||||
source: 'fake',
|
||||
ring: outer,
|
||||
polygons: shapes[target],
|
||||
footprintKind: 'area',
|
||||
});
|
||||
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');
|
||||
await engine.annotate({ type: 'area', target: 'same', label: 'Same' });
|
||||
assert.equal(engine.list().length, 2, 'identical parts and holes: one mark');
|
||||
});
|
||||
|
||||
test('a street highlight asks for the street outline', () => {
|
||||
assert.deepEqual(
|
||||
promoteStreetHighlight({ type: 'highlight', target: 'Congress Avenue' }),
|
||||
{ type: 'area', target: 'Congress Avenue', entityKind: 'street' },
|
||||
);
|
||||
assert.equal(
|
||||
promoteStreetHighlight({ type: 'highlight', target: 'Lombard St, San Francisco' }).type,
|
||||
'area',
|
||||
);
|
||||
assert.equal(
|
||||
promoteStreetHighlight({ type: 'highlight', target: 'the Alamo', entityKind: 'street' }).type,
|
||||
'area',
|
||||
);
|
||||
for (const spec of [
|
||||
{ type: 'highlight', target: "St Peter's Basilica" },
|
||||
{ type: 'highlight', target: 'Zilker Park' },
|
||||
{ type: 'highlight', target: 'Congress Avenue', footprint: false },
|
||||
{ type: 'highlight', target: 'Congress Avenue', entityKind: 'building' },
|
||||
{ type: 'pin', target: 'Congress Avenue' },
|
||||
])
|
||||
assert.equal(promoteStreetHighlight(spec), spec, JSON.stringify(spec));
|
||||
});
|
||||
|
||||
test('the engine resolves a bare street highlight with its footprint', async (t) => {
|
||||
installAnimationFrameStubs(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 };
|
||||
};
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
bufferCorridor,
|
||||
ringAreaM2,
|
||||
ringCentroid,
|
||||
} from '../sources/featureGeometry.js';
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import {
|
||||
BUILDING_MAX_DISTANCE_M,
|
||||
OUTLINE_TILE_ZOOM,
|
||||
STREET_RADIUS_M,
|
||||
boxAround,
|
||||
buildingFromTiles,
|
||||
createOpenFreeMapOutlineSource,
|
||||
enclosingAreaFromTiles,
|
||||
streetFromTiles,
|
||||
} from '../sources/openFreeMapOutlines.js';
|
||||
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
|
||||
|
||||
/**
|
||||
* Outline rungs that need no Overpass, around the resolver's existing ladder:
|
||||
*
|
||||
* bundled rungs (inside the base ladder)
|
||||
* → OpenFreeMap tiles for streets and pointed-at buildings
|
||||
* → operator Overpass, when configured (the base ladder, unchanged)
|
||||
* → the server's guarded Nominatim outline (cities, landmarks, grounds,
|
||||
* parks, lakes, campuses, named buildings)
|
||||
* → OpenFreeMap building at the point, for a named building nobody outlined
|
||||
* → the base answer: an honest point marked "outline unavailable".
|
||||
*
|
||||
* Every rung returns the resolver's footprint shape
|
||||
* `{ ring, polygons?, kind, heightM, synthesized }` and hands it to the
|
||||
* resolver's `finish`, which applies the scope caps and drift bound and
|
||||
* re-centres the anchor. Nothing here fetches without an explicit ask.
|
||||
*/
|
||||
|
||||
/** Half-width (m) of the ribbon drawn along a street. */
|
||||
const STREET_HALF_WIDTH_M = 11;
|
||||
|
||||
/** A Nominatim "grounds" answer smaller than this is a building, not grounds. */
|
||||
const GROUNDS_MIN_AREA_M2 = 20_000;
|
||||
|
||||
/** Words that point at the thing rather than name it. */
|
||||
const DEICTIC_RE =
|
||||
/^(?:(?:this|that|the|my)\s+)?(?:building|house|structure|tower|place|one|thing|spot|here|there|it)$/i;
|
||||
|
||||
/** Words that describe grounds rather than belong to the name searched. */
|
||||
const GROUNDS_WORDS_RE =
|
||||
/\b(?:grounds|compound|campus|premises|property|site|area|complex)\b/gi;
|
||||
|
||||
/** Whether the ask names a thing (vs. "this building" at a pointer). */
|
||||
export function isNamedAsk(target) {
|
||||
const text = String(target || '').trim();
|
||||
return Boolean(text) && !DEICTIC_RE.test(text) && /\p{L}/u.test(text);
|
||||
}
|
||||
|
||||
/** The words sent to Nominatim: the ask without grounds words or articles. */
|
||||
export function outlineQueryText(target) {
|
||||
return String(target || '')
|
||||
.replace(GROUNDS_WORDS_RE, ' ')
|
||||
.replace(/^\s*the\s+/i, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Nominatim ask kind (if any) suits a resolver scope.
|
||||
* Neighbourhoods and streets are not sent: bundled packs and tiles own them.
|
||||
*/
|
||||
export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
|
||||
if (pointLike) return null;
|
||||
// "Around" asks get a buffer, except grounds: the grounds ARE the thing.
|
||||
if (around && !groundsLike) return null;
|
||||
if (scope === 'city') return 'city';
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country')
|
||||
return 'admin';
|
||||
if (groundsLike) return 'landmark';
|
||||
if (scope === 'building') return 'building';
|
||||
if (scope === 'compound' || scope === 'auto') return 'landmark';
|
||||
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.
|
||||
*/
|
||||
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));
|
||||
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
|
||||
if (at === 0) return polygons;
|
||||
}
|
||||
return polygons;
|
||||
}
|
||||
|
||||
/** Whether a base answer is final (a real outline, or "try again later"). */
|
||||
function keepBase(result) {
|
||||
if (result === undefined) return true;
|
||||
if (result?.rateLimited === true) return true;
|
||||
if (isUnavailableCapability(result)) return false;
|
||||
return Boolean(result?.ring) && !result.synthesized;
|
||||
}
|
||||
|
||||
/** Street polylines → one ribbon polygon per chain (longest first). */
|
||||
export function streetFootprint(street) {
|
||||
const polygons = street.lines
|
||||
.filter((line) => line.length >= 2)
|
||||
.map((line) => [bufferCorridor(line, STREET_HALF_WIDTH_M)]);
|
||||
if (!polygons.length) return null;
|
||||
return {
|
||||
ring: polygons[0][0],
|
||||
polygons,
|
||||
lines: street.lines,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
outlineSource: 'openfreemap',
|
||||
};
|
||||
}
|
||||
|
||||
export function createOutlineRungs({
|
||||
tiles = createOpenFreeMapOutlineSource(),
|
||||
nominatim = createNominatimOutlineClient(),
|
||||
} = {}) {
|
||||
/** Decoded tiles around a point, or null when the tiles cannot be read. */
|
||||
async function tilesAround(lat, lon, radiusM, signal) {
|
||||
try {
|
||||
const { tiles: decoded } = await tiles.fetchBounds(
|
||||
boxAround(lat, lon, radiusM),
|
||||
{ zoom: OUTLINE_TILE_ZOOM, signal },
|
||||
);
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (error?.name === 'AbortError') throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function streetRung(ctx) {
|
||||
const name = ctx.matchName || ctx.target;
|
||||
if (!isNamedAsk(name)) return null;
|
||||
const decoded = await tilesAround(
|
||||
ctx.lat,
|
||||
ctx.lon,
|
||||
STREET_RADIUS_M,
|
||||
ctx.signal,
|
||||
);
|
||||
if (!decoded) return null;
|
||||
const street = streetFromTiles(decoded, {
|
||||
name,
|
||||
lat: ctx.lat,
|
||||
lon: ctx.lon,
|
||||
});
|
||||
return street ? streetFootprint(street) : null;
|
||||
}
|
||||
|
||||
async function buildingRung(ctx) {
|
||||
const decoded = await tilesAround(
|
||||
ctx.lat,
|
||||
ctx.lon,
|
||||
BUILDING_MAX_DISTANCE_M * 2,
|
||||
ctx.signal,
|
||||
);
|
||||
const building = decoded && buildingFromTiles(decoded, ctx);
|
||||
if (!building) return null;
|
||||
return {
|
||||
ring: building.ring,
|
||||
polygons: [building.rings],
|
||||
kind: 'building',
|
||||
heightM: building.heightM,
|
||||
synthesized: false,
|
||||
outlineSource: 'openfreemap',
|
||||
};
|
||||
}
|
||||
|
||||
/** The open area enclosing `seed` (the named feature) or the anchor. */
|
||||
async function groundsRung(ctx, seed = null) {
|
||||
const at = seed || { lat: ctx.lat, lon: ctx.lon };
|
||||
const decoded = await tilesAround(at.lat, at.lon, 600, ctx.signal);
|
||||
const area =
|
||||
decoded &&
|
||||
enclosingAreaFromTiles(decoded, {
|
||||
lat: at.lat,
|
||||
lon: at.lon,
|
||||
minAreaM2: GROUNDS_MIN_AREA_M2,
|
||||
});
|
||||
if (!area) return null;
|
||||
return {
|
||||
ring: area.ring,
|
||||
polygons: [area.rings],
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
outlineSource: 'openfreemap',
|
||||
};
|
||||
}
|
||||
|
||||
async function nominatimRung(ctx, kind) {
|
||||
const query = outlineQueryText(
|
||||
kind === 'city' || kind === 'admin'
|
||||
? ctx.matchName || ctx.target
|
||||
: ctx.target || ctx.matchName,
|
||||
);
|
||||
if (!isNamedAsk(query)) return null;
|
||||
const bias = ctx.view || { lat: ctx.lat, lon: ctx.lon };
|
||||
const outline = await nominatim.lookup(
|
||||
{ query, kind, lat: bias.lat, lon: bias.lon },
|
||||
{ signal: ctx.signal },
|
||||
);
|
||||
if (!outline?.polygons) return outline; // null | undefined | unavailable | rateLimited
|
||||
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)
|
||||
return { groundsSeed: ringCentroid(ring) };
|
||||
return {
|
||||
ring,
|
||||
polygons,
|
||||
kind:
|
||||
kind === 'building' || outline.class === 'building'
|
||||
? 'building'
|
||||
: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
outlineSource: 'nominatim',
|
||||
outlineName: outline.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the ladder for one resolved anchor.
|
||||
*
|
||||
* @param {object} ctx scope, target, matchName, lat, lon, named anchor facts,
|
||||
* view centre, signal, and `credit(kind)`.
|
||||
* @param {{base: () => Promise<any>, finish: (fp: object, scope: string) => any}} ladder
|
||||
*/
|
||||
async function resolve(ctx, { base, finish }) {
|
||||
const { scope } = ctx;
|
||||
const named = ctx.fromName && isNamedAsk(ctx.target);
|
||||
// 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) => {
|
||||
alive();
|
||||
if (!fp) return null;
|
||||
const patch = finish(fp, asScope);
|
||||
if (patch?.ring) {
|
||||
ctx.credit?.(fp.outlineSource === 'openfreemap' ? 'tiles' : 'osm');
|
||||
// One line per answered outline, like the resolver's own trace.
|
||||
console.log(
|
||||
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${fp.outlineSource}` +
|
||||
` (${patch.footprintKind}, ${patch.polygons?.length || 1} part(s))`,
|
||||
);
|
||||
return { ...patch, outlineSource: fp.outlineSource };
|
||||
}
|
||||
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');
|
||||
if (street) return street;
|
||||
} else if (scope === 'building' && !named && !ctx.around) {
|
||||
const building = accept(await buildingRung(ctx), 'building');
|
||||
if (building) return building;
|
||||
}
|
||||
|
||||
alive();
|
||||
const baseResult = await base();
|
||||
alive();
|
||||
if (keepBase(baseResult)) return baseResult;
|
||||
|
||||
// A transient or busy Nominatim answer is kept aside while the tile
|
||||
// fallbacks run; it is returned only if nothing else answers.
|
||||
let retryLater = null;
|
||||
let groundsSeed = null;
|
||||
const kind = named ? nominatimKindFor(ctx) : null;
|
||||
if (kind) {
|
||||
const found = await nominatimRung(ctx, kind);
|
||||
alive();
|
||||
if (found?.groundsSeed) {
|
||||
groundsSeed = found.groundsSeed;
|
||||
} else if (found?.ring) {
|
||||
const patch = accept(found, ctx.groundsLike ? 'compound' : scope);
|
||||
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.
|
||||
if (ctx.groundsLike && named) {
|
||||
// Around the named building when Nominatim found it, else the anchor.
|
||||
let grounds = groundsSeed
|
||||
? accept(await groundsRung(ctx, groundsSeed), 'compound')
|
||||
: null;
|
||||
grounds ||= accept(await groundsRung(ctx), 'compound');
|
||||
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');
|
||||
if (building) return building;
|
||||
}
|
||||
// Keep the mark retryable only when nothing else could answer. A
|
||||
// definitive miss or an approximation from the configured ladder stands
|
||||
// rather than inviting repeated lookups.
|
||||
if (retryLater && isUnavailableCapability(baseResult))
|
||||
return retryLater.value;
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
return { resolve };
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// OVERPASS-FREE OUTLINE RUNGS.
|
||||
//
|
||||
// Streets and pointed-at buildings come from OpenFreeMap tiles before the
|
||||
// existing ladder; cities, landmarks, grounds and named buildings fall to the
|
||||
// server's guarded Nominatim outline after it. With default settings no
|
||||
// request reaches any Overpass endpoint.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
createOutlineRungs,
|
||||
isNamedAsk,
|
||||
nominatimKindFor,
|
||||
outlineQueryText,
|
||||
} from './outlineRungs.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { decodeOpenFreeMapOutlineTile } from '../sources/openFreeMapOutlines.js';
|
||||
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
|
||||
import { createApplicationRequestServices } from '../services/requests.js';
|
||||
import { overpassProxy } from '../../server/providers/overpass.js';
|
||||
import { geocodeProxy } from '../../server/providers/regional/place.js';
|
||||
import { createNominatimGate } from '../../server/providers/regional/nominatimGate.js';
|
||||
|
||||
const TILES = [6745, 6746].map((y) =>
|
||||
decodeOpenFreeMapOutlineTile(
|
||||
readFileSync(
|
||||
new URL(`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`, import.meta.url),
|
||||
),
|
||||
14,
|
||||
3743,
|
||||
y,
|
||||
),
|
||||
);
|
||||
const fixtureTiles = () => {
|
||||
const asked = [];
|
||||
return {
|
||||
asked,
|
||||
fetchBounds: async (box, options) => {
|
||||
asked.push({ box, zoom: options.zoom });
|
||||
return { tiles: TILES };
|
||||
},
|
||||
};
|
||||
};
|
||||
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
|
||||
|
||||
const square = (lat, lon, halfDeg) => [
|
||||
[lon - halfDeg, lat - halfDeg],
|
||||
[lon + halfDeg, lat - halfDeg],
|
||||
[lon + halfDeg, lat + halfDeg],
|
||||
[lon - halfDeg, lat + halfDeg],
|
||||
[lon - halfDeg, lat - halfDeg],
|
||||
];
|
||||
|
||||
function fakeNominatim(answer) {
|
||||
const asks = [];
|
||||
return {
|
||||
asks,
|
||||
lookup: async (ask) => {
|
||||
asks.push(ask);
|
||||
return typeof answer === 'function' ? answer(ask) : answer;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A ladder whose base answers `baseAnswer` and whose finish passes rings through. */
|
||||
function ladder(baseAnswer) {
|
||||
const calls = { base: 0, finished: [] };
|
||||
return {
|
||||
calls,
|
||||
base: async () => {
|
||||
calls.base += 1;
|
||||
return baseAnswer;
|
||||
},
|
||||
finish: (fp, scope) => {
|
||||
calls.finished.push(scope);
|
||||
return { ring: fp.ring, polygons: fp.polygons, footprintKind: fp.kind, synthesized: false };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const UNAVAILABLE = { unavailable: true, retryable: false, code: 'OVERPASS_NOT_CONFIGURED' };
|
||||
const ctx = (overrides) => ({
|
||||
scope: 'auto',
|
||||
target: 'X',
|
||||
matchName: 'X',
|
||||
fromName: true,
|
||||
pointLike: false,
|
||||
groundsLike: false,
|
||||
around: false,
|
||||
...CAPITOL,
|
||||
view: { lat: CAPITOL.lat, lon: CAPITOL.lon },
|
||||
credit() {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('ask wording helpers', () => {
|
||||
assert.equal(isNamedAsk('this building'), false);
|
||||
assert.equal(isNamedAsk('here'), false);
|
||||
assert.equal(isNamedAsk('Texas Capitol'), true);
|
||||
assert.equal(outlineQueryText('the Texas Capitol grounds'), 'Texas Capitol');
|
||||
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: 'neighborhood' }), null);
|
||||
assert.equal(nominatimKindFor({ scope: 'street' }), null);
|
||||
assert.equal(nominatimKindFor({ scope: 'compound', pointLike: true }), null);
|
||||
});
|
||||
|
||||
test('a street comes from tiles before the configured ladder, as merged ribbons', async () => {
|
||||
const tiles = fixtureTiles();
|
||||
const nominatim = fakeNominatim(null);
|
||||
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) }),
|
||||
run,
|
||||
);
|
||||
assert.ok(result.ring.length > 6);
|
||||
assert.equal(result.polygons.length, 1);
|
||||
assert.equal(result.outlineSource, 'openfreemap');
|
||||
assert.equal(run.calls.base, 0, 'the ladder is not consulted');
|
||||
assert.deepEqual(run.calls.finished, ['street']);
|
||||
assert.deepEqual(credits, ['tiles']);
|
||||
assert.equal(tiles.asked[0].zoom, 14);
|
||||
assert.equal(nominatim.asks.length, 0, 'streets never reach Nominatim');
|
||||
});
|
||||
|
||||
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' }),
|
||||
run,
|
||||
);
|
||||
assert.equal(result, UNAVAILABLE);
|
||||
assert.equal(run.calls.base, 1);
|
||||
});
|
||||
|
||||
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(
|
||||
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);
|
||||
});
|
||||
|
||||
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(
|
||||
ctx({ scope: 'building', target: 'Texas State Capitol' }),
|
||||
ladder(UNAVAILABLE),
|
||||
);
|
||||
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 () => {
|
||||
const ring = square(43.6, 1.44, 0.05);
|
||||
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) }),
|
||||
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(credits, ['osm']);
|
||||
});
|
||||
|
||||
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 land = square(CAPITOL.lat, CAPITOL.lon, 0.2);
|
||||
const nominatim = fakeNominatim({ polygons: [[sea], [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');
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
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(
|
||||
ctx({ scope: 'compound', target: 'Zilker Park' }),
|
||||
ladder(undefined),
|
||||
);
|
||||
assert.equal(result, undefined);
|
||||
assert.equal(nominatim.asks.length, 0);
|
||||
});
|
||||
|
||||
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 busy = { rateLimited: true, retryAfterMs: 5000 };
|
||||
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);
|
||||
});
|
||||
|
||||
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 }),
|
||||
ladder(UNAVAILABLE),
|
||||
);
|
||||
assert.equal(small.outlineSource, 'openfreemap');
|
||||
assert.equal(small.footprintKind, 'area');
|
||||
// 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));
|
||||
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 }),
|
||||
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']]);
|
||||
});
|
||||
|
||||
test('point-like, around and neighbourhood asks never reach Nominatim', async () => {
|
||||
for (const extra of [
|
||||
{ scope: 'compound', pointLike: true, target: 'Tejano Monument' },
|
||||
{ scope: 'compound', around: true, target: 'Zilker Park' },
|
||||
{ scope: 'neighborhood', target: 'Mission District' },
|
||||
]) {
|
||||
const nominatim = fakeNominatim(null);
|
||||
await createOutlineRungs({ tiles: fixtureTiles(), nominatim }).resolve(ctx(extra), ladder(UNAVAILABLE));
|
||||
assert.equal(nominatim.asks.length, 0, JSON.stringify(extra));
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ end to end
|
||||
|
||||
function routes(...plugins) {
|
||||
const found = new Map();
|
||||
for (const plugin of plugins)
|
||||
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}/`))
|
||||
.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)] : []);
|
||||
Object.assign(req, {
|
||||
method: init.method || 'GET',
|
||||
url: parsed.pathname.slice(mount.length) + parsed.search || '/',
|
||||
headers: {},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
});
|
||||
let status;
|
||||
let headers;
|
||||
let body;
|
||||
await handlers.get(mount)(
|
||||
req,
|
||||
{
|
||||
on() {},
|
||||
writeHead(s, h) {
|
||||
status = s;
|
||||
headers = h;
|
||||
},
|
||||
end(b) {
|
||||
body = b;
|
||||
},
|
||||
},
|
||||
() => {
|
||||
status = 404;
|
||||
body = '{}';
|
||||
},
|
||||
);
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
|
||||
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)] } },
|
||||
],
|
||||
'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)] } },
|
||||
],
|
||||
};
|
||||
|
||||
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'] },
|
||||
};
|
||||
|
||||
const viewerAt = (lat, lon, height = 4000) => ({
|
||||
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) => {
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
delete process.env.OVERPASS_UPSTREAMS;
|
||||
t.after(() => {
|
||||
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}`));
|
||||
|
||||
const upstream = [];
|
||||
const gate = createNominatimGate({
|
||||
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');
|
||||
return Response.json(NOMINATIM_ROWS[q] || []);
|
||||
},
|
||||
sleep: async () => {},
|
||||
});
|
||||
const handlers = routes(overpassProxy(), geocodeProxy({ gate }));
|
||||
const local = [];
|
||||
const transport = (url, init) => {
|
||||
local.push(`${init?.method || 'GET'} ${String(url).split('?')[0]}`);
|
||||
return call(handlers, url, init);
|
||||
};
|
||||
const services = createApplicationRequestServices({ fetchImpl: transport });
|
||||
const resolver = createAnnotationResolver({
|
||||
featureSource: services.features,
|
||||
outlineRungs: createOutlineRungs({
|
||||
tiles: fixtureTiles(),
|
||||
nominatim: createNominatimOutlineClient({ fetchImpl: transport }),
|
||||
}),
|
||||
});
|
||||
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 });
|
||||
return { result, outline: await result.resolveOutline() };
|
||||
};
|
||||
|
||||
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 street = await resolve(viewerAt(30.27, -97.742), { target: 'Congress Avenue' });
|
||||
assert.equal(street.outline?.outlineSource, 'openfreemap');
|
||||
|
||||
const building = await resolve(viewerAt(30.2747, -97.7403, 800), {
|
||||
latitude: CAPITOL.lat,
|
||||
longitude: CAPITOL.lon,
|
||||
entityKind: 'building',
|
||||
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.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.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 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;
|
||||
},
|
||||
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),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.equal(run.calls.base, 0);
|
||||
assert.equal(nominatim.asks.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// Tile fallbacks still run after a busy or failed Nominatim answer, and
|
||||
// grounds asks phrased "around" still reach their outline rungs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createOutlineRungs, nominatimKindFor } from './outlineRungs.js';
|
||||
import { decodeOpenFreeMapOutlineTile } from '../sources/openFreeMapOutlines.js';
|
||||
|
||||
const TILES = [6745, 6746].map((y) =>
|
||||
decodeOpenFreeMapOutlineTile(
|
||||
readFileSync(
|
||||
new URL(
|
||||
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
14,
|
||||
3743,
|
||||
y,
|
||||
),
|
||||
);
|
||||
const UNAVAILABLE = { unavailable: true, retryable: false };
|
||||
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
|
||||
|
||||
function setup(answer) {
|
||||
const tileCalls = [];
|
||||
const asks = [];
|
||||
const rungs = createOutlineRungs({
|
||||
tiles: {
|
||||
fetchBounds: async (box) => {
|
||||
tileCalls.push(box);
|
||||
return { tiles: TILES };
|
||||
},
|
||||
},
|
||||
nominatim: {
|
||||
lookup: async (ask) => {
|
||||
asks.push(ask);
|
||||
return answer;
|
||||
},
|
||||
},
|
||||
});
|
||||
return { rungs, tileCalls, asks };
|
||||
}
|
||||
const ladder = {
|
||||
base: async () => UNAVAILABLE,
|
||||
finish: (fp) => ({ ring: fp.ring, footprintKind: fp.kind }),
|
||||
};
|
||||
const ctx = (overrides) => ({
|
||||
scope: 'building',
|
||||
target: 'Texas State Capitol',
|
||||
matchName: 'Texas State Capitol',
|
||||
fromName: true,
|
||||
...CAPITOL,
|
||||
view: CAPITOL,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
for (const [label, answer] of [
|
||||
['busy', { rateLimited: true, retryAfterMs: 5000 }],
|
||||
['transient', undefined],
|
||||
]) {
|
||||
test(`a ${label} Nominatim answer does not skip the tile fallbacks`, async () => {
|
||||
const building = setup(answer);
|
||||
const found = await building.rungs.resolve(ctx(), ladder);
|
||||
assert.equal(building.asks.length, 1);
|
||||
assert.ok(building.tileCalls.length > 0, 'tiles were consulted');
|
||||
assert.equal(found.footprintKind, 'building');
|
||||
|
||||
const grounds = setup(answer);
|
||||
const lawn = await grounds.rungs.resolve(
|
||||
ctx({ target: 'the Texas Capitol grounds', groundsLike: true }),
|
||||
ladder,
|
||||
);
|
||||
assert.equal(lawn.footprintKind, 'area');
|
||||
assert.equal(lawn.outlineSource, 'openfreemap');
|
||||
});
|
||||
}
|
||||
|
||||
test('with no tile answer either, the busy result keeps the mark retryable', async () => {
|
||||
const busy = { rateLimited: true, retryAfterMs: 5000 };
|
||||
const rungs = createOutlineRungs({
|
||||
tiles: { fetchBounds: async () => ({ tiles: [] }) },
|
||||
nominatim: { lookup: async () => busy },
|
||||
});
|
||||
assert.equal(await rungs.resolve(ctx(), ladder), busy);
|
||||
});
|
||||
|
||||
test('"around the Capitol grounds" is still a grounds ask', async () => {
|
||||
assert.equal(
|
||||
nominatimKindFor({ scope: 'building', groundsLike: true, around: true }),
|
||||
'landmark',
|
||||
);
|
||||
assert.equal(nominatimKindFor({ scope: 'compound', around: true }), null);
|
||||
const { rungs, asks } = setup(null);
|
||||
const lawn = await rungs.resolve(
|
||||
ctx({
|
||||
target: 'the Texas Capitol grounds',
|
||||
groundsLike: true,
|
||||
around: true,
|
||||
}),
|
||||
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 () => {
|
||||
// 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;
|
||||
const dome = {
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[CAPITOL.lon - d, CAPITOL.lat - d],
|
||||
[CAPITOL.lon + d, CAPITOL.lat - d],
|
||||
[CAPITOL.lon + d, CAPITOL.lat + d],
|
||||
[CAPITOL.lon - d, CAPITOL.lat + d],
|
||||
[CAPITOL.lon - d, CAPITOL.lat - d],
|
||||
],
|
||||
],
|
||||
],
|
||||
class: 'office',
|
||||
};
|
||||
const withoutSeed = setup(null);
|
||||
assert.equal(
|
||||
await withoutSeed.rungs.resolve(
|
||||
ctx({
|
||||
...offLawn,
|
||||
target: 'the Texas Capitol grounds',
|
||||
groundsLike: true,
|
||||
}),
|
||||
ladder,
|
||||
),
|
||||
UNAVAILABLE,
|
||||
'the anchor alone is not inside the grounds',
|
||||
);
|
||||
const { rungs } = setup(dome);
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
// Area annotations draw bundled US Census place and neighborhood outlines
|
||||
// (San Francisco DataSF first, then Who's On First) with no network lookup.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
function harness(place = null, { outlineRungs } = {}) {
|
||||
const lookups = [];
|
||||
const featureSource = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
featureSource[method] = async () => {
|
||||
lookups.push(method);
|
||||
return null;
|
||||
};
|
||||
const geocodes = [];
|
||||
const placeSearch = {
|
||||
async geocode(query) {
|
||||
geocodes.push(query);
|
||||
return { place };
|
||||
},
|
||||
async textSearch() {
|
||||
geocodes.push('text-search');
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { resolveAnnotationTarget } = createAnnotationResolver({
|
||||
featureSource,
|
||||
...(outlineRungs ? { outlineRungs } : {}),
|
||||
});
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
...extra,
|
||||
});
|
||||
return { resolve, lookups, geocodes };
|
||||
}
|
||||
|
||||
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
|
||||
const SAN_FRANCISCO = viewerAt(37.76, -122.42, 3000);
|
||||
const LONDON = viewerAt(51.51, -0.16, 3000);
|
||||
|
||||
function assertBundledArea(resolved, label) {
|
||||
assert.equal(resolved?.source, 'bundled', label);
|
||||
assert.equal(resolved.label, label);
|
||||
assert.equal(resolved.footprintKind, 'area');
|
||||
assert.equal(resolved.synthesized, false);
|
||||
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
|
||||
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
|
||||
assert.ok(resolved.polygons.length >= 1);
|
||||
}
|
||||
|
||||
test('a city name that is only a neighborhood alias goes to the geocoder', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// Sé lists "São Paulo" among its names; the ask means the city.
|
||||
const saoPaulo = viewerAt(-23.55, -46.633, 60_000);
|
||||
const city = await resolve('São Paulo', saoPaulo);
|
||||
assert.notEqual(city?.label, 'Se');
|
||||
assert.ok(geocodes.includes('São Paulo'));
|
||||
// A district ask still takes the alias.
|
||||
const district = await resolve('São Paulo', saoPaulo, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assert.equal(district?.label, 'Se');
|
||||
});
|
||||
|
||||
test('"Austin" over Austin draws the Census place with no lookup', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const target of ['Austin', 'Austin, Texas', 'City of Austin']) {
|
||||
const resolved = await resolve(target, AUSTIN);
|
||||
assertBundledArea(resolved, 'Austin');
|
||||
assert.ok(resolved.polygons.length >= 4, 'every part of the city');
|
||||
}
|
||||
// A qualified name needs no view.
|
||||
const springfield = await resolve('Springfield, Illinois', AUSTIN);
|
||||
assertBundledArea(springfield, 'Springfield');
|
||||
assert.ok(Math.abs(springfield.lat - 39.78) < 0.2);
|
||||
assert.deepEqual(geocodes, []);
|
||||
assert.deepEqual(lookups, []);
|
||||
});
|
||||
|
||||
test("the SF DataSF pack answers before Who's On First in San Francisco", async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
const sf = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../data/local_data/neighborhoods/san-francisco.json',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
const mission = sf.features.find((f) => f.properties.name === 'Mission');
|
||||
const missionRing = mission.geometry.coordinates[0];
|
||||
for (const target of ['the Mission District', 'Mission', 'the Mission']) {
|
||||
const resolved = await resolve(target, SAN_FRANCISCO, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assertBundledArea(resolved, 'Mission');
|
||||
assert.deepEqual(resolved.polygons[0][0], missionRing, target);
|
||||
}
|
||||
// A name only WOF has in San Francisco falls through to it.
|
||||
const dogpatch = await resolve('Dogpatch', SAN_FRANCISCO);
|
||||
assertBundledArea(dogpatch, 'Dogpatch');
|
||||
// A park asked for as a compound is not a neighborhood of its name.
|
||||
await resolve('Mission Dolores Park', SAN_FRANCISCO, {
|
||||
entityKind: 'compound',
|
||||
});
|
||||
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 1);
|
||||
assert.deepEqual(lookups, []);
|
||||
});
|
||||
|
||||
test("Who's On First neighborhoods resolve near the view", async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
assertBundledArea(await resolve('Notting Hill', LONDON), 'Notting Hill');
|
||||
assertBundledArea(
|
||||
await resolve('Le Marais', viewerAt(48.857, 2.352, 3000)),
|
||||
'Le Marais',
|
||||
);
|
||||
assertBundledArea(
|
||||
await resolve('Williamsburg', viewerAt(40.71, -73.96, 3000)),
|
||||
'Williamsburg',
|
||||
);
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('campuses, qualified hoods and far names still go to the geocoder', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// Stanford is a CDP, but a campus ask wants the campus.
|
||||
const stanford = await resolve('Stanford', viewerAt(37.43, -122.17, 3000), {
|
||||
entityKind: 'compound',
|
||||
});
|
||||
assert.notEqual(stanford?.source, 'bundled');
|
||||
await resolve('the Capitol grounds', AUSTIN);
|
||||
await resolve('Notting Hill', AUSTIN);
|
||||
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 3);
|
||||
// A state settles a place name wherever the camera is: not Brooklyn's
|
||||
// Williamsburg, the Virginia city.
|
||||
const virginia = await resolve(
|
||||
'Williamsburg, Virginia',
|
||||
viewerAt(40.71, -73.96, 3000),
|
||||
);
|
||||
assertBundledArea(virginia, 'Williamsburg');
|
||||
assert.ok(Math.abs(virginia.lat - 37.27) < 0.1, 'Virginia');
|
||||
});
|
||||
|
||||
test('a geocoded city or neighborhood upgrades to the bundled outline', async () => {
|
||||
const city = harness({
|
||||
lat: 30.2672,
|
||||
lng: -97.7431,
|
||||
label: 'Austin, TX, USA',
|
||||
name: 'Austin',
|
||||
types: ['locality', 'political'],
|
||||
});
|
||||
// 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');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.equal(outline.adminArea, 'Austin');
|
||||
assert.ok(outline.polygons.length >= 4);
|
||||
assert.deepEqual(city.lookups, [], 'no boundary lookup');
|
||||
|
||||
const hood = harness({
|
||||
lat: 51.5118,
|
||||
lng: -0.2046,
|
||||
label: 'Notting Hill, London, UK',
|
||||
name: 'Notting Hill',
|
||||
types: ['neighborhood', 'political'],
|
||||
});
|
||||
const nottingHill = await hood.resolve(
|
||||
'Notting Hill',
|
||||
viewerAt(48, 10, 5_000_000),
|
||||
);
|
||||
const hoodOutline = await nottingHill.resolveOutline();
|
||||
assert.equal(hoodOutline.adminArea, 'Notting Hill');
|
||||
assert.deepEqual(hood.lookups, []);
|
||||
});
|
||||
|
||||
test('a district ask prefers a neighborhood; a place must hold the view', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// "Richmond" over SF's Richmond District: never Richmond, California.
|
||||
const richmond = await resolve('Richmond', viewerAt(37.78, -122.48, 3000), {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assertBundledArea(richmond, 'Richmond District');
|
||||
assert.ok(Math.abs(richmond.lat - 37.78) < 0.05, 'not the East Bay city');
|
||||
// Omitted kind at a street-level view: the district in view, too.
|
||||
const bare = await resolve('Richmond', viewerAt(37.78, -122.48, 3000));
|
||||
assertBundledArea(bare, 'Richmond District');
|
||||
// Over the East Bay city, the city.
|
||||
const wide = await resolve('Richmond', viewerAt(37.94, -122.35, 40_000));
|
||||
assert.equal(wide?.source, 'bundled');
|
||||
assert.ok(wide.lat > 37.88, 'Richmond, California');
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('landmark-named neighborhoods wait for the landmark ladder', async () => {
|
||||
const manhattan = viewerAt(40.78, -73.965, 3000);
|
||||
const cases = [
|
||||
['Central Park', {}],
|
||||
['Central Park', { entityKind: 'compound' }],
|
||||
['Central Park', { intent: 'around_the_thing' }],
|
||||
['Hyde Park', {}],
|
||||
['Notting Hill', { intent: 'around_the_thing' }],
|
||||
];
|
||||
for (const [target, extra] of cases) {
|
||||
const { resolve, geocodes } = harness();
|
||||
const viewer = target === 'Central Park' ? manhattan : LONDON;
|
||||
const resolved = await resolve(target, viewer, extra);
|
||||
assert.notEqual(
|
||||
resolved?.source,
|
||||
'bundled',
|
||||
`${target} ${JSON.stringify(extra)}`,
|
||||
);
|
||||
assert.ok(geocodes.length >= 1, `${target} is geocoded`);
|
||||
}
|
||||
// Named as a district, the WOF neighborhood answers.
|
||||
const { resolve } = harness();
|
||||
const park = await resolve('Central Park', manhattan, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assert.equal(park?.source, 'bundled');
|
||||
// "Park Slope" ends in no landmark word: still a neighborhood.
|
||||
const slope = await resolve('Park Slope', viewerAt(40.671, -73.98, 3000));
|
||||
assertBundledArea(slope, 'Park Slope');
|
||||
});
|
||||
|
||||
/** Outline rungs whose Nominatim answers `answer` (null: nothing found). */
|
||||
function rungsAnswering(answer) {
|
||||
const asked = [];
|
||||
return {
|
||||
asked,
|
||||
resolve: async (ctx, { base, finish }) => {
|
||||
asked.push({ scope: ctx.scope, target: ctx.target });
|
||||
const baseResult = await base();
|
||||
if (baseResult?.ring && !baseResult.synthesized) return baseResult;
|
||||
return answer ? finish(answer, ctx.scope) : baseResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PARIS_PLACE = {
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
label: 'Paris, France',
|
||||
name: 'Paris',
|
||||
types: ['locality', 'political'],
|
||||
};
|
||||
const square = (lat, lon, d) => [
|
||||
[lon - d, lat - d],
|
||||
[lon + d, lat - d],
|
||||
[lon + d, lat + d],
|
||||
[lon - d, lat + d],
|
||||
[lon - d, lat - d],
|
||||
];
|
||||
|
||||
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 });
|
||||
const fine = harness(PARIS_PLACE, { outlineRungs: rungs });
|
||||
const resolved = await fine.resolve('Paris', overParis);
|
||||
assert.equal(
|
||||
resolved.source,
|
||||
'geocode',
|
||||
'the 11-vertex admin-1 is not drawn at once',
|
||||
);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.deepEqual(outline.ring, ring, 'the finer outline wins');
|
||||
assert.deepEqual(rungs.asked, [{ scope: 'city', target: 'Paris' }]);
|
||||
|
||||
// Nothing finer (Nominatim off, busy or capped): the bundled shape, not a point.
|
||||
const coarse = harness(PARIS_PLACE, { outlineRungs: rungsAnswering(null) });
|
||||
const fallback = await (
|
||||
await coarse.resolve('Paris', overParis)
|
||||
).resolveOutline();
|
||||
assert.equal(fallback.adminArea, 'Paris');
|
||||
assert.ok(fallback.ring.length < 32);
|
||||
|
||||
// No geocoder answer at all: the bundled shape at once.
|
||||
const offline = harness(null, { outlineRungs: rungsAnswering(null) });
|
||||
const direct = await offline.resolve('Paris', overParis);
|
||||
assert.equal(direct.source, 'bundled');
|
||||
assert.equal(direct.label, 'Paris');
|
||||
});
|
||||
|
||||
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
|
||||
const { resolve } = harness(null, { outlineRungs: rungsAnswering(null) });
|
||||
for (const [name, lat, lon] of [
|
||||
['Berlin', 52.52, 13.4],
|
||||
['Bavaria', 48.9, 11.4],
|
||||
['Texas', 31, -99],
|
||||
]) {
|
||||
const resolved = await resolve(name, viewerAt(lat, lon, 400_000));
|
||||
assert.equal(resolved?.source, 'bundled', name);
|
||||
}
|
||||
// Countries are never coarse cities, however few vertices.
|
||||
const singapore = await resolve('Singapore', viewerAt(1.35, 103.82, 60_000));
|
||||
assert.equal(singapore?.source, 'bundled');
|
||||
// An explicit admin ask takes the admin unit as it is.
|
||||
const dc = await resolve('Paris', viewerAt(48.8566, 2.3522, 40_000), {
|
||||
entityKind: 'state',
|
||||
});
|
||||
assert.equal(dc?.source, 'bundled');
|
||||
});
|
||||
|
||||
test('a campus ask reaches the campus, not the Census place of its town', async () => {
|
||||
const stanfordTown = {
|
||||
lat: 37.4241,
|
||||
lng: -122.1661,
|
||||
label: 'Stanford, CA, USA',
|
||||
name: 'Stanford',
|
||||
types: ['locality', 'political'],
|
||||
};
|
||||
const campus = square(37.4275, -122.1697, 0.01);
|
||||
for (const ask of ['Stanford University', 'Stanford campus']) {
|
||||
const rungs = rungsAnswering({ ring: campus, kind: 'area', heightM: null });
|
||||
const { resolve } = harness(stanfordTown, { outlineRungs: rungs });
|
||||
const resolved = await resolve(ask, viewerAt(37.43, -122.17, 8000));
|
||||
assert.notEqual(resolved?.source, 'bundled', ask);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.deepEqual(outline.ring, campus, ask);
|
||||
assert.equal(
|
||||
rungs.asked[0].scope,
|
||||
'compound',
|
||||
`${ask}: past the Census rung`,
|
||||
);
|
||||
}
|
||||
// "Stanford" said to be a compound is the campus too.
|
||||
const kindRungs = rungsAnswering({
|
||||
ring: campus,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
});
|
||||
const byKind = harness(stanfordTown, { outlineRungs: kindRungs });
|
||||
const compound = await (
|
||||
await byKind.resolve('Stanford', viewerAt(37.43, -122.17, 8000), {
|
||||
entityKind: 'compound',
|
||||
})
|
||||
).resolveOutline();
|
||||
assert.deepEqual(compound.ring, campus);
|
||||
assert.equal(kindRungs.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, {
|
||||
outlineRungs: rungsAnswering(null),
|
||||
});
|
||||
const town = await (
|
||||
await resolve('Stanford', viewerAt(39, -100, 5_000_000))
|
||||
).resolveOutline();
|
||||
assert.equal(town.adminArea, 'Stanford');
|
||||
const station = harness(
|
||||
{
|
||||
lat: 30.628,
|
||||
lng: -96.3344,
|
||||
label: 'College Station, TX, USA',
|
||||
name: 'College Station',
|
||||
types: ['locality', 'political'],
|
||||
},
|
||||
{ outlineRungs: rungsAnswering(null) },
|
||||
);
|
||||
const collegeStation = await (
|
||||
await station.resolve('College Station', viewerAt(39, -100, 5_000_000))
|
||||
).resolveOutline();
|
||||
assert.equal(collegeStation.adminArea, 'College Station');
|
||||
});
|
||||
|
||||
test('a grounds ask never takes the state the geocoder typed', async () => {
|
||||
const texas = {
|
||||
lat: 31.0,
|
||||
lng: -99.0,
|
||||
label: 'Texas, USA',
|
||||
name: 'Texas',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
};
|
||||
for (const [ask, extra] of [
|
||||
['the Texas Capitol grounds', {}],
|
||||
['Texas Capitol', { entityKind: 'compound' }],
|
||||
// The grounds wording is only in the label.
|
||||
['Texas Capitol', { labelHint: 'Capitol grounds' }],
|
||||
]) {
|
||||
const rungs = rungsAnswering(null);
|
||||
const { resolve } = harness(texas, { outlineRungs: rungs });
|
||||
const resolved = await resolve(ask, viewerAt(31, -99, 3_000_000), extra);
|
||||
assert.notEqual(resolved?.source, 'bundled', ask);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.notEqual(outline?.adminArea, 'Texas', ask);
|
||||
assert.equal(rungs.asked[0].scope, 'compound', ask);
|
||||
}
|
||||
});
|
||||
+299
-12
@@ -19,18 +19,50 @@ import {
|
||||
lookupNaturalRegionOutline,
|
||||
findNaturalRegion,
|
||||
} from '../data/naturalEarthRegions.js';
|
||||
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
|
||||
import {
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
polygonsContain as polygonsContainPoint,
|
||||
} from '../data/adminBoundaries.js';
|
||||
import {
|
||||
findNeighborhoodArea,
|
||||
findNeighborhoodAreaAt,
|
||||
findPlaceArea,
|
||||
findPlaceAreaAt,
|
||||
NEIGHBORHOOD_NEAR_KM,
|
||||
normalizePlaceName,
|
||||
PLACE_NEAR_KM,
|
||||
} from '../data/placeBoundaries.js';
|
||||
import {
|
||||
registerDynamicCredit,
|
||||
NATURAL_EARTH_CREDIT,
|
||||
US_CENSUS_CREDIT,
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
WOF_CREDIT,
|
||||
} from '../data/dataCredits.js';
|
||||
import { createOutlineRungs } from './outlineRungs.js';
|
||||
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;
|
||||
|
||||
/**
|
||||
* Names that end like a landmark rather than a district: "Central Park",
|
||||
* "Lake Merritt"-style suffixes are checked on the last word only, so "Park
|
||||
* Slope" and "Notting Hill" stay districts.
|
||||
*/
|
||||
const LANDMARK_NAME =
|
||||
/\b(?:park|gardens?|square|plaza|lake|river|bay|beach|pier|harbou?r|airport|station|stadium|arena|cemetery|zoo|university|college|campus|palace|castle|cathedral|church|temple|mosque|bridge|market|mall|cent(?:er|re)|tower|museum|reservoir|forest|woods?|heath|common|green)$/i;
|
||||
|
||||
/** The name part of an ask, before any comma qualifier. */
|
||||
function stripQualifiers(query) {
|
||||
return String(query || '')
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
'building',
|
||||
@@ -47,6 +79,8 @@ export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
signal: lifetime,
|
||||
}),
|
||||
// Overpass-free rungs (OpenFreeMap tiles, guarded Nominatim) around the ladder.
|
||||
outlineRungs = createOutlineRungs(),
|
||||
} = {}) {
|
||||
requireFeatureSource(featureSource);
|
||||
lifetime?.throwIfAborted();
|
||||
@@ -176,6 +210,11 @@ export function createAnnotationResolver({
|
||||
// locality suffixes; the types classify the entity (point-like vs area-like).
|
||||
let placesPrimary = null;
|
||||
let placeTypes = [];
|
||||
// A coarse bundled admin-1 shape that carries a city's name (Natural
|
||||
// Earth's 11-vertex "Paris"): kept as the fallback while the finer rungs
|
||||
// (Census, Nominatim) try first.
|
||||
let coarseCityShape = null;
|
||||
const askNamesAdmin = Boolean(adminScopeFromAsk(target, entityKind));
|
||||
// Instrumentation (logged once per target at the end): which sources were tried and what they returned.
|
||||
const trace = {
|
||||
query: String(target || '').trim(),
|
||||
@@ -201,7 +240,26 @@ export function createAnnotationResolver({
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
|
||||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||||
coarseCityShape = admin;
|
||||
else if (admin) return bundledAdminTarget(viewer, admin, trace.query);
|
||||
// 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)
|
||||
) {
|
||||
const area = await bundledAreaNear(trace.query, center, {
|
||||
entityKind,
|
||||
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (area) return bundledAdminTarget(viewer, area, trace.query);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
@@ -329,7 +387,10 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
return coarseCityShape
|
||||
? bundledAdminTarget(viewer, coarseCityShape, trace.query)
|
||||
: null;
|
||||
|
||||
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
|
||||
// several Texas Capitol monuments landed blocks-to-miles apart, looking "all over the map"). When
|
||||
@@ -394,7 +455,9 @@ export function createAnnotationResolver({
|
||||
`osmSnap=${trace.osmSnap} → FINAL source=rejected (proximity gate, ${drift.driftKm.toFixed(0)}km > ${drift.limitKm.toFixed(0)}km)`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return coarseCityShape
|
||||
? bundledAdminTarget(viewer, coarseCityShape, trace.query)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +477,23 @@ export function createAnnotationResolver({
|
||||
// an admin boundary for a city/state, the enclosing compound for a mall/campus, a single
|
||||
// building for a premise. The voice model's entityKind (an entity FACT) refines an
|
||||
// unresolved 'auto' scope only — real geocode types always win.
|
||||
const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
|
||||
// A campus or grounds ask ("Stanford University", "Stanford campus",
|
||||
// "the Capitol grounds", in the target or its label) or a compound by
|
||||
// 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(
|
||||
typedScope,
|
||||
)
|
||||
? 'compound'
|
||||
: typedScope;
|
||||
// Point-like targets (monuments/statues/memorials/…) resolve POINT-FIRST: only an
|
||||
// (almost) exactly-named, monument-scale polygon may replace the point; a nearby polygon
|
||||
// sharing locality words must not (docs/field-test-rootcause-2026-06-30.md §1).
|
||||
@@ -443,7 +522,7 @@ export function createAnnotationResolver({
|
||||
* The inline path awaits it right here; progressive callers (deferFootprint) invoke it
|
||||
* AFTER the anchor mark is drawn and upgrade the mark in place.
|
||||
*/
|
||||
const resolveOutline = async () => {
|
||||
const resolveBaseOutline = async () => {
|
||||
let scope = baseScope;
|
||||
const isAdmin =
|
||||
scope === 'country' ||
|
||||
@@ -471,6 +550,7 @@ export function createAnnotationResolver({
|
||||
);
|
||||
if (ne) {
|
||||
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
|
||||
traceBundledOutline(ne.name, 'natural-earth');
|
||||
const neCentroid = ringCentroid(ne.ring);
|
||||
return {
|
||||
ring: ne.ring,
|
||||
@@ -526,7 +606,18 @@ export function createAnnotationResolver({
|
||||
lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return bundledAdminOutline(viewer, admin);
|
||||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||||
coarseCityShape ||= admin;
|
||||
else if (admin) return bundledAdminOutline(viewer, admin);
|
||||
}
|
||||
// A geocoded US city, town or CDP: the Census place that holds it.
|
||||
if (scope === 'city') {
|
||||
const place = await findPlaceAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
).catch(() => null);
|
||||
if (place) return bundledAdminOutline(viewer, place);
|
||||
}
|
||||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||||
// building/landuse (a city is never a single building).
|
||||
@@ -537,7 +628,19 @@ 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);
|
||||
if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null };
|
||||
if (ext) {
|
||||
traceBundledOutline(ext.name || matchName, 'datasf');
|
||||
fp = { ring: ext.ring, kind: 'area', heightM: null };
|
||||
}
|
||||
// THEN a bundled Who's On First neighborhood carrying the name at the point.
|
||||
if (!fp) {
|
||||
const wof = await findNeighborhoodAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
).catch(() => null);
|
||||
if (wof) return bundledAdminOutline(viewer, wof);
|
||||
}
|
||||
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
|
||||
// returns a footprint, null (definitively no polygon), or undefined (transient
|
||||
// upstream failure). Synthesize a blob (the "Mission" problem, research §3b) ONLY
|
||||
@@ -663,6 +766,11 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
}
|
||||
return finishOutline(fp, scope);
|
||||
};
|
||||
|
||||
/** Validate a footprint for its scope and re-centre it (resolveOutline's patch). */
|
||||
const finishOutline = (fp, scope) => {
|
||||
// Scope sanity: reject a real footprint whose area is wildly wrong for the asked
|
||||
// scope (a building/compound/neighborhood intent must never draw a state-sized
|
||||
// blob). Synthesized discs are deliberately sized and exempt.
|
||||
@@ -693,17 +801,51 @@ export function createAnnotationResolver({
|
||||
footprintKind: fp.kind,
|
||||
buildingHeight: fp.heightM,
|
||||
synthesized: Boolean(fp.synthesized),
|
||||
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
|
||||
lat: centroid.lat,
|
||||
lon: centroid.lon,
|
||||
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
|
||||
};
|
||||
};
|
||||
|
||||
// Streets and pointed-at buildings from tiles, then this ladder, then the
|
||||
// guarded Nominatim outline (see outlineRungs.js).
|
||||
const outlineContext = {
|
||||
scope: baseScope,
|
||||
target,
|
||||
matchName,
|
||||
lat,
|
||||
lon,
|
||||
fromName: fromGeocode || source === 'places' || source === 'osm-local',
|
||||
pointLike,
|
||||
groundsLike,
|
||||
around: intent === 'around_the_thing',
|
||||
view: viewportProximity(viewer),
|
||||
signal,
|
||||
credit: (kind) =>
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
|
||||
),
|
||||
};
|
||||
const resolveOutline = async () => {
|
||||
const result = await outlineRungs.resolve(outlineContext, {
|
||||
base: resolveBaseOutline,
|
||||
finish: finishOutline,
|
||||
});
|
||||
// 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);
|
||||
return result;
|
||||
};
|
||||
|
||||
let ring = null;
|
||||
let footprintKind = null; // 'building' | 'area'
|
||||
let buildingHeight = null; // meters, only for buildings
|
||||
let outlineUnavailable = false;
|
||||
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
|
||||
let polygons = null; // every part of a multi-part outline, when known
|
||||
if (footprint && !deferFootprint) {
|
||||
const fp = await resolveOutline();
|
||||
outlineUnavailable = isUnavailableCapability(fp);
|
||||
@@ -712,6 +854,7 @@ export function createAnnotationResolver({
|
||||
footprintKind = fp.footprintKind;
|
||||
buildingHeight = fp.buildingHeight;
|
||||
synthesized = fp.synthesized;
|
||||
polygons = fp.polygons || null;
|
||||
// Re-center the anchor on the resolved footprint centroid.
|
||||
lat = fp.lat;
|
||||
lon = fp.lon;
|
||||
@@ -739,6 +882,7 @@ export function createAnnotationResolver({
|
||||
source,
|
||||
synthesized,
|
||||
outlineUnavailable,
|
||||
...(polygons ? { polygons } : {}),
|
||||
viewport: placeViewport,
|
||||
...(footprint && deferFootprint ? { resolveOutline } : {}),
|
||||
};
|
||||
@@ -778,13 +922,109 @@ export function createAnnotationResolver({
|
||||
const GROUNDS_RADIUS_M = 300; // "X grounds/compound/campus" loose disc when OSM has no polygon
|
||||
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
|
||||
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
|
||||
const COARSE_CITY_MAX_KM2 = 5_000; // city-sized admin-1 units (see isCoarseCityShape)
|
||||
const COARSE_CITY_MAX_VERTICES = 32;
|
||||
|
||||
/** Credit the pack a bundled boundary came from. */
|
||||
function creditAdminSource(viewer, admin) {
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
|
||||
);
|
||||
const credit = {
|
||||
'natural-earth': NATURAL_EARTH_CREDIT,
|
||||
'us-census': US_CENSUS_CREDIT,
|
||||
wof: WOF_CREDIT,
|
||||
}[admin.source];
|
||||
if (credit) registerDynamicCredit(viewer, credit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundled US place or neighborhood an ask names at or near the view,
|
||||
* from Census places, the San Francisco DataSF pack (first in SF), then
|
||||
* Who's On First. How far "near" reaches follows the view (half its
|
||||
* radius); a state-qualified place ("Springfield, Illinois") needs no
|
||||
* view. A district ask takes a neighborhood first and a place only when
|
||||
* the view is inside it ("Richmond" over SF is not Richmond, CA). Without
|
||||
* an entity kind the candidate that holds the view wins, else the nearer.
|
||||
* A name that ends like a landmark ("Central Park", "Lake Merritt") is
|
||||
* left to the landmark ladder unless the ask is a district.
|
||||
*/
|
||||
async function bundledAreaNear(
|
||||
query,
|
||||
center,
|
||||
{ entityKind = null, viewRadiusKm = null } = {},
|
||||
) {
|
||||
const district = entityKind === 'district';
|
||||
if (!district && LANDMARK_NAME.test(stripQualifiers(query))) return null;
|
||||
const reach = Number.isFinite(viewRadiusKm) ? viewRadiusKm / 2 : null;
|
||||
const clamp = (km, lo, hi) =>
|
||||
km === null ? hi : Math.min(hi, Math.max(lo, km));
|
||||
const placeReach = district ? 0 : clamp(reach, 3, PLACE_NEAR_KM);
|
||||
const place = await findPlaceArea(query, {
|
||||
near: center,
|
||||
nearKm: placeReach,
|
||||
}).catch(() => null);
|
||||
const holds = (area) =>
|
||||
Boolean(center) &&
|
||||
polygonsContainPoint(area.polygons, center.lat, center.lon);
|
||||
// A district ask only takes a place the view is inside (or one a state
|
||||
// qualifier named outright).
|
||||
const placeOk =
|
||||
place && (!district || holds(place) || /,/.test(query)) ? place : null;
|
||||
if (!center) return placeOk;
|
||||
let hood = null;
|
||||
const sf = await lookupNeighborhoodRing(center.lat, center.lon, query, {
|
||||
exact: true,
|
||||
}).catch(() => null);
|
||||
if (sf) hood = sfNeighborhoodArea(sf);
|
||||
else
|
||||
hood = await findNeighborhoodArea(query, {
|
||||
near: center,
|
||||
nearKm: clamp(reach, 1, NEIGHBORHOOD_NEAR_KM),
|
||||
}).catch(() => null);
|
||||
// Outside a district ask, an alternate name unrelated to the name is too
|
||||
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
|
||||
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
|
||||
if (
|
||||
!district &&
|
||||
hood?.byAlias &&
|
||||
!normalizePlaceName(hood.name).includes(
|
||||
normalizePlaceName(stripQualifiers(query)),
|
||||
)
|
||||
)
|
||||
hood = null;
|
||||
if (!hood || !placeOk) return district ? hood || placeOk : placeOk || hood;
|
||||
if (district) return hood;
|
||||
const hoodHolds = holds(hood);
|
||||
const placeHolds = holds(placeOk);
|
||||
if (hoodHolds !== placeHolds) return hoodHolds ? hood : placeOk;
|
||||
const distance = (area) =>
|
||||
approximateDistanceM(
|
||||
center.lat,
|
||||
center.lon,
|
||||
area.label.lat,
|
||||
area.label.lon,
|
||||
);
|
||||
return distance(hood) <= distance(placeOk) ? hood : placeOk;
|
||||
}
|
||||
|
||||
/** An SF DataSF ring in the bundled-area shape `bundledAdminTarget` takes. */
|
||||
function sfNeighborhoodArea({ ring, name }) {
|
||||
const lons = ring.map((p) => p[0]);
|
||||
const lats = ring.map((p) => p[1]);
|
||||
return {
|
||||
kind: 'neighborhood',
|
||||
name,
|
||||
region: null,
|
||||
source: 'datasf',
|
||||
polygons: [[ring]],
|
||||
ring,
|
||||
bbox: [
|
||||
Math.min(...lons),
|
||||
Math.min(...lats),
|
||||
Math.max(...lons),
|
||||
Math.max(...lats),
|
||||
],
|
||||
label: ringCentroid(ring),
|
||||
candidates: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -796,6 +1036,7 @@ export function createAnnotationResolver({
|
||||
*/
|
||||
function bundledAdminOutline(viewer, admin) {
|
||||
creditAdminSource(viewer, admin);
|
||||
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
|
||||
const { lat, lon } = admin.label;
|
||||
return {
|
||||
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
|
||||
@@ -810,6 +1051,31 @@ export function createAnnotationResolver({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a bundled unit is too coarse to stand for a city of its name: a
|
||||
* Natural Earth admin-1 at city size (up to 5,000 km²) drawn with fewer
|
||||
* than 32 vertices. Paris (11), Buenos Aires (11), Washington DC (9),
|
||||
* Vienna and Prague (23) and Seoul (28) are; Berlin (48), Hamburg (43),
|
||||
* Bremen (39) and every larger state are not. Countries never are.
|
||||
*/
|
||||
function isCoarseCityShape(admin) {
|
||||
if (admin?.source !== 'natural-earth' || admin.kind !== 'state')
|
||||
return false;
|
||||
if (!(admin.areaKm2 <= COARSE_CITY_MAX_KM2)) return false;
|
||||
const vertices = (admin.polygons || []).reduce(
|
||||
(n, poly) => n + poly.reduce((m, ring) => m + ring.length, 0),
|
||||
0,
|
||||
);
|
||||
return vertices < COARSE_CITY_MAX_VERTICES;
|
||||
}
|
||||
|
||||
/** One trace line per outline a bundled pack answered (same shape as outlineRungs). */
|
||||
function traceBundledOutline(name, source, parts = 1) {
|
||||
console.log(
|
||||
`[Outline] "${name}": bundled → ${source} (${parts || 1} part(s))`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||||
function bundledAdminTarget(viewer, admin, query) {
|
||||
const outline = bundledAdminOutline(viewer, admin);
|
||||
@@ -1561,6 +1827,19 @@ export function createAnnotationResolver({
|
||||
/** A target that reads like an enclosing GROUNDS / COMPOUND / CAMPUS (e.g. "Texas Capitol grounds")
|
||||
* rather than a single building — used to synthesize a loose-footprint disc when OSM has no real
|
||||
* polygon, instead of failing outright. */
|
||||
/**
|
||||
* A university, college or campus named as such: "Stanford University",
|
||||
* "Boston College", "University of Texas", "Stanford campus". Towns named
|
||||
* after one ("College Station", "University Park") are not.
|
||||
*/
|
||||
function isCampusAsk(query) {
|
||||
const text = stripQualifiers(String(query || '')).trim();
|
||||
return (
|
||||
/\b(?:university|college|campus)$/i.test(text) ||
|
||||
/^(?:the\s+)?(?:university|college)\s+of\b/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isGroundsLikeQuery(query) {
|
||||
return /\b(grounds|compound|campus|complex|quad|plaza)\b/i.test(
|
||||
String(query || ''),
|
||||
@@ -2079,6 +2358,14 @@ export function createAnnotationResolver({
|
||||
).catch(() => null);
|
||||
if (admin) return { name: q, ring: [...admin.ring] };
|
||||
}
|
||||
if (scope === 'city') {
|
||||
const place = await findPlaceAreaAt(
|
||||
[q, geo.primaryName],
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
).catch(() => null);
|
||||
if (place) return { name: q, ring: [...place.ring] };
|
||||
}
|
||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user