diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f42ce3ea..0d2c3c35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Keep landmark names when geocoding returns only address components, preventing + the United States Capitol annotation from moving to a Washington hotel. + Unrelated outlines leave the valid geocoded marker in place. + - Split aircraft and vessel server providers into focused modules for source fetching, AIS records/tracks and shared request helpers; preserve existing routes, local setup, fallback behavior and rendering. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 99f83d62c..3233be2b3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,15 @@ # God's Eye View Current State +## Landmark annotation identity + +When a landmark geocode contains only address components, annotations retain +its requested name for outline matching. A city or neighborhood address no +longer replaces the landmark's identity. Without a canonical feature name, +outline candidates must contain the geocoded anchor or closely match the +requested name; otherwise the annotation stays at its geocoded point. +Genuine feature-name components and existing administrative/monument matching +retain their established behavior. + ## Build configuration and local provider boundaries `vite.config.js` delegates to `server/standalone/vite.config.js`, which loads diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index 3bcc143c2..fa557abcc 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -395,7 +395,11 @@ export async function resolveAnnotationTarget({ // (which classifies building-vs-area and stitches compound multipolygons). // Point-like targets use the strict 'point' selection: exact-ish name + monument // scale, else no polygon at all — the honest point beats a locality-word match. - fp = await fetchFootprint(lat, lon, matchName, scope, signal, pointLike ? 'point' : 'loose'); + // Without a feature-name component, match the original ask and require + // identity or containment before replacing its geocoded anchor. + const footprintMode = pointLike ? 'point' + : (fromGeocode && !usablePrimary ? 'anchored' : 'loose'); + fp = await fetchFootprint(lat, lon, matchName, scope, signal, footprintMode); // A "grounds/compound/campus" phrase ("Texas Capitol grounds") names an ENCLOSING area. The // primary footprint above returns the BUILDING (the dome) or null — neither is the grounds. The // real enclosing polygon (e.g. "Capitol Square", leisure=park) IS in OSM but only surfaces via a @@ -715,9 +719,9 @@ async function placesTextSearch(query, centerLat, centerLon, radiusM, signal) { /** * The canonical name of the geocoded feature: the address component whose own * types match the result's feature type (e.g. the `neighborhood` component for a - * neighborhood result), falling back to the first component / leading label - * token. This is the user's INTENT, stripped of the trailing admin context that - * makes the raw utterance match the wrong-scope OSM feature. + * neighborhood result). Address-only responses may identify a landmark's city + * without naming the landmark itself. Return null in that case so the caller + * keeps the requested name instead of matching a nearby building in that city. */ function extractPrimaryName(result) { const resultTypes = new Set((result.types || []).map((t) => String(t).toLowerCase())); @@ -726,8 +730,7 @@ function extractPrimaryName(result) { const ct = (c.types || []).map((t) => String(t).toLowerCase()); if (ct.some((t) => t !== 'political' && resultTypes.has(t))) return c.long_name; } - if (comps[0]?.long_name) return comps[0].long_name; - return String(result.formatted_address || '').split(',')[0].trim() || null; + return null; } /** @@ -1222,6 +1225,7 @@ function perpDistance(p, a, b) { * 'loose' — default word-overlap scoring (generic POIs/compounds). * 'strict' — named, non-building, district-sized areas only (neighborhood fallback). * 'point' — point-like targets: (almost) exactly-named, monument-scale polygons only. + * 'anchored' — address-only geocodes: require name identity or anchor containment. * * @returns {Promise, kind: 'building'|'area', heightM: number|null }>} */ @@ -1487,7 +1491,7 @@ const POINTLIKE_AREA_CAP_M2 = 60_000; /** * Pick the best OSM polygon for a query from raw Overpass elements. `mode` selects the - * acceptance contract ('loose' | 'strict' | 'point' — see fetchFootprint). Exported for + * acceptance contract ('loose' | 'strict' | 'point' | 'anchored' — see fetchFootprint). Exported for * the unit tests, which pin the mode contracts with fixtures captured from live data. */ export function selectFootprint(elements, targetLat, targetLon, query, mode = 'loose') { @@ -1539,6 +1543,10 @@ export function selectFootprint(elements, targetLat, targetLon, query, mode = 'l } const contains = pointInPolygon(targetLon, targetLat, coords); + if (mode === 'anchored' && !contains) { + const intentCoverage = queryWords.size ? nameOverlap / queryWords.size : 0; + if (!named || intentCoverage < 0.5 || completeness < 0.6) continue; + } const centroid = ringCentroid(coords.map((p) => [p.lon, p.lat])); const distanceM = centroid ? approximateDistanceM(targetLat, targetLon, centroid.lat, centroid.lon) diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index 1ab378683..af67a2c0c 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -362,3 +362,72 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township 'both township-level admin result types stay guarded', ); }); + +// The Capitol geocoder response contains its coordinates but only ADDRESS names. +// Synthetic footprints reproduce the real Capitol/YOTEL selection without live APIs. +const CAPITOL_ANCHOR = { lat: 38.8899389, lon: -77.0090505 }; +function capitolViewer() { + return { camera: { positionCartographic: { + latitude: CAPITOL_ANCHOR.lat * Math.PI / 180, + longitude: CAPITOL_ANCHOR.lon * Math.PI / 180, + height: 1000, + } } }; +} +function installCapitolMocks(t, elements, components = [ + { long_name: 'Washington', types: ['locality', 'political'] }, + { long_name: 'Capitol Hill', types: ['neighborhood', 'political'] }, +]) { + installGoogleMocks(t, async (url) => { + if (String(url).startsWith('https://maps.googleapis.com/')) { + return { json: async () => ({ status: 'OK', results: [{ + formatted_address: 'Washington, DC 20004, USA', + types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'], + address_components: components, + geometry: { location: { lat: CAPITOL_ANCHOR.lat, lng: CAPITOL_ANCHOR.lon } }, + }] }) }; + } + assert.equal(String(url), '/api/overpass'); + return { ok: true, status: 200, json: async () => ({ elements }) }; + }); +} + +for (const [target, deferFootprint] of [ + ['United States Capitol', true], + ['United States Capitol, Washington, DC', false], +]) { + test(`address-only geocode retains landmark identity: ${target}`, async t => { + const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); + const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' }); + installCapitolMocks(t, [hotel, capitol]); + const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint }); + assert.ok(result); + const outline = deferFootprint ? await result.resolveOutline() : result; + assert.deepEqual(outline.ring, capitol.geometry.map(p => [p.lon, p.lat])); + assert.equal(outline.footprintKind, 'building'); + }); +} + +test('address-only landmark without a matching outline keeps its geocoded point', async t => { + const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' }); + installCapitolMocks(t, [hotel]); + const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true }); + assert.ok(result); + assert.deepEqual([result.lat, result.lon], [CAPITOL_ANCHOR.lat, CAPITOL_ANCHOR.lon]); + assert.equal(await result.resolveOutline(), null); +}); + +test('missing address components do not turn a formatted city address into the landmark name', async t => { + const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); + const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'YOTEL Washington DC' }); + installCapitolMocks(t, [hotel, capitol], []); + const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'United States Capitol building', footprint: true }); + assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat])); +}); + +test('a genuine geocoded feature name still canonicalizes an alternate user name', async t => { + const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); + const decoy = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'Congress meeting building' }); + installCapitolMocks(t, [decoy, capitol], [{ long_name: 'United States Capitol', types: ['landmark'] }]); + const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'Congress meeting building', footprint: true }); + assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat])); +});