From 1a61d02b7da5339898a16fd3e4682014df4ee329 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:59:51 -0700 Subject: [PATCH] fix(geocode): a 'place, region' query lands in the region, not on a namesake From Denver, 'Paris, Texas' flew to a pub in Budapest at rooftop range. The camera-biased Photon answer listed the town first, but the pub's name equals the whole query, and the biased path matched names only. A query with a region now takes a candidate named for the place whose city, county, state, country or country code (or USA/UK for US/GB) contains every word of the region, in both the biased and the unbiased pass; with no such candidate the existing name rules decide. A query with no region after its comma keeps the biased name match. Co-Authored-By: Claude Opus 5.5 --- src/keylessGeocoder.js | 51 +++++++++++++++++++++++++++++- src/keylessGeocoder.test.mjs | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 6033a6776..fb82ac84e 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -266,6 +266,50 @@ export function selectPhotonFeature( return null; } +/** Short country names people type that Photon's fields don't carry. */ +const COUNTRY_ALIASES = Object.freeze({ US: 'usa', GB: 'uk' }); + +/** The region part of a "place, region" query, normalized ('' if none). */ +function queryRegion(query) { + const [, ...rest] = String(query ?? '').split(','); + return normalizeToponym(rest.join(' ')); +} + +/** + * For a "place, region" query, pick the candidate named for the place that + * actually lies in the region. "Paris, Texas" is also the name of a pub in + * Budapest: matched as one name it beat the town, so the region has to be + * checked against where each candidate is, not against its name. + * @param {object[]} features - Photon features, in the order returned. + * @param {string} query - The query as typed. + * @returns {?object} null when the query names no region or nothing fits. + */ +export function selectQualifiedFeature(features, query) { + const [headText] = String(query ?? '').split(','); + const region = queryRegion(query); + const head = normalizeToponym(headText); + if (!head || !region) return null; + const words = region.split(' '); + return ( + (Array.isArray(features) ? features : []).find((feature) => { + const properties = feature?.properties ?? {}; + const name = normalizeToponym(properties.name); + if (name !== head && !name.startsWith(`${head} `)) return false; + const where = ` ${normalizeToponym( + [ + properties.city, + properties.county, + properties.state, + properties.country, + properties.countrycode, + COUNTRY_ALIASES[String(properties.countrycode ?? '').toUpperCase()], + ].join(' '), + )} `; + return words.every((word) => where.includes(` ${word} `)); + }) ?? null + ); +} + /** Evict the oldest memo entries until the cache is within its cap. */ function trimPhotonCache(cache) { while (cache.size > PHOTON_CACHE_MAX) { @@ -344,12 +388,16 @@ export async function geocodeKeylessWithOutcome( // to the wrong continent. So a biased answer is only accepted when it leads // with the name asked for; otherwise the same query runs unbiased, which is // what a user searching for somewhere off-screen meant in the first place. + // A query naming a region is only answered nearby by a match inside it. let feature = null; let everyAskAnswered = true; if (bias) { const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint })); if (biased === null) everyAskAnswered = false; - else feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); + else + feature = queryRegion(trimmed) + ? selectQualifiedFeature(biased, trimmed) + : selectPhotonFeature(biased, normalizeToponym(trimmed)); } signal?.throwIfAborted(); @@ -364,6 +412,7 @@ export async function geocodeKeylessWithOutcome( // the thing being searched for; the tail only says where to look. const head = normalizeToponym(trimmed.split(',')[0]); feature = + selectQualifiedFeature(anywhere, trimmed) || selectPhotonFeature(anywhere, head, { allowContains: true }) || anywhere[0]; } diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 2ce8a70b4..bd6b96f59 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -30,6 +30,7 @@ import { photonResultTypes, photonSearchUrl, selectPhotonFeature, + selectQualifiedFeature, } from './keylessGeocoder.js'; import { geocodeNavigationMode } from './locations.js'; @@ -288,6 +289,52 @@ test('a "place, region" query is matched on the place, not the region', async () assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`); }); +/** Photon's real answers for "Paris, Texas" biased to Denver, then unbiased. */ +const PARIS_TX = { + geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] }, + properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Paris', state: 'Texas', country: 'United States', countrycode: 'US' }, +}; +const PARIS_TEXAS_PUB = { + geometry: { type: 'Point', coordinates: [19.0606, 47.4868] }, + properties: { osm_key: 'amenity', osm_value: 'pub', type: 'house', name: 'Paris, Texas', city: 'Budapest', country: 'Magyarország', countrycode: 'HU' }, +}; +const PARIS_TEXAS_SALON = { + geometry: { type: 'Point', coordinates: [153.03, -27.47] }, + properties: { osm_key: 'shop', osm_value: 'hairdresser', type: 'house', name: 'Paris Texas', city: 'Brisbane', state: 'Queensland', country: 'Australia', countrycode: 'AU' }, +}; + +test('"Paris, Texas" is the town in Texas, not a pub of that name in Budapest', async () => { + // Measured from Denver against the live service: the biased answer listed the + // town first, but the pub's name equals the whole query and won, flying the + // camera to Budapest at rooftop range. + const fetchImpl = photonPair([PARIS_TX, PARIS_TEXAS_PUB, PARIS_TEXAS_SALON], [PARIS_TX]); + + const result = await geocodeKeyless('Paris, Texas', { bias: '39.70,-104.90|39.80,-104.80', fetchImpl }); + + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a region is checked against where a candidate is, not its name', () => { + const pubFirst = [PARIS_TEXAS_PUB, PARIS_TEXAS_SALON, PARIS_TX]; + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas'), PARIS_TX); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, United States'), PARIS_TX); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, US'), PARIS_TX); + // No region, or a region nothing lies in, leaves the choice to the name rules. + assert.equal(selectQualifiedFeature(pubFirst, 'Paris'), null); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Ontario'), null); + assert.equal(selectQualifiedFeature(undefined, 'Paris, Texas'), null); +}); + +test('a region nearby fits nothing, so the unbiased answer decides', async () => { + const fetchImpl = photonPair([PARIS_TEXAS_PUB], [PARIS_TX, PARIS_TEXAS_PUB]); + + const result = await geocodeKeyless('Paris, Texas', { bias: '47.40,19.00|47.50,19.10', fetchImpl }); + + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 2); +}); + test('accents and punctuation do not change what a name compares as', () => { assert.equal(normalizeToponym('Huế'), 'hue'); assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay'); @@ -452,3 +499,16 @@ function readGeocodingConsumer(file) { ? [file, 'annotations/resolver.js'] : [file]; return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); } + +test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => { + const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX]; + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX); + // "Paris," names no region: the camera-biased pass still answers. + const fetchImpl = photonPair([PARIS_TX], []); + const result = await geocodeKeyless('Paris,', { + bias: '33.60,-95.60|33.70,-95.50', + fetchImpl, + }); + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +});