Merge pull request #1401 from quanticsoul4772/upstream/geocode-place-region

fix(geocode): match place-and-region queries by location
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-28 16:11:13 +00:00
commit 6d16b479d7
4 files changed
+330 -3

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
- A "place, region" search such as "Paris, Texas" now lands on the place in
that region instead of a namesake elsewhere (quanticsoul4772, #1401).
+7 -1
View File
@@ -5276,7 +5276,13 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers
candidates, and an unbiased retry for name mismatches. A "place, region" query
prefers a candidate named exactly for the place whose city, county, state,
country or country code (or USA/UK) contains every word of the region, and a
settlement or administrative area over a POI when both fit. Nearby results that
fail that check are retried unbiased. After the region checks of both passes, a
nearby place whose whole name is the query (one with a comma in it) comes next,
then the unbiased name match. Invalid or wrapped bounds returned by providers
are omitted rather than framing the wrong part of the globe.
A location search that finds nothing, or fails, leaves the field open with the
+81 -1
View File
@@ -281,6 +281,67 @@ 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' });
/**
* OSM keys of settlements and administrative areas. Among candidates that fit
* a "place, region" query, these are what the region names a place in: a cafe
* called "Paris" in Lyon is in France too, but "Paris, France" means the city.
*/
const SETTLEMENT_OSM_KEYS = Object.freeze(new Set(['place', 'boundary']));
/** 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. When several
* candidates fit, a settlement or administrative area wins over a POI.
* @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(' ');
const qualified = (Array.isArray(features) ? features : []).filter(
(feature) => {
const properties = feature?.properties ?? {};
const name = normalizeToponym(properties.name);
// Exactly the place: "Paris Gare de Lyon" is in France too, but it
// is not what "Paris, France" asks for.
if (name !== 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} `));
},
);
return (
qualified.find((feature) =>
SETTLEMENT_OSM_KEYS.has(feature?.properties?.osm_key),
) ??
qualified[0] ??
null
);
}
/** Evict the oldest memo entries until the cache is within its cap. */
function trimPhotonCache(cache) {
while (cache.size > PHOTON_CACHE_MAX) {
@@ -359,12 +420,28 @@ 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 answered nearby only by a match inside it.
// A nearby place whose own name has a comma ("Bed, Bath & Beyond") and
// equals the whole query is kept as the last choice, after the region
// checks of both passes.
let feature = null;
let nearbyNameMatch = 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 if (queryRegion(trimmed)) {
feature = selectQualifiedFeature(biased, trimmed);
// Exactly the whole query: "Springfield IL Auto" only starts with it.
const whole = normalizeToponym(trimmed);
nearbyNameMatch =
biased.find(
(candidate) =>
normalizeToponym(candidate?.properties?.name) === whole,
) ?? null;
} else {
feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
}
}
signal?.throwIfAborted();
@@ -379,9 +456,12 @@ 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) ||
nearbyNameMatch ||
selectPhotonFeature(anywhere, head, { allowContains: true }) ||
anywhere[0];
}
if (!feature) feature = nearbyNameMatch;
}
signal?.throwIfAborted();
+240 -1
View File
@@ -30,6 +30,7 @@ import {
photonResultTypes,
photonSearchUrl,
selectPhotonFeature,
selectQualifiedFeature,
} from './keylessGeocoder.js';
import { geocodeNavigationMode } from './locations.js';
@@ -288,6 +289,245 @@ 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('"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);
});
test('a region match takes the place itself, not a place whose name starts with it', () => {
const station = {
geometry: { type: 'Point', coordinates: [4.86, 45.76] },
properties: {
osm_key: 'railway',
name: 'Paris Gare de Lyon',
city: 'Paris',
country: 'France',
countrycode: 'FR',
},
};
const paris = {
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
properties: {
osm_key: 'place',
name: 'Paris',
state: 'Île-de-France',
country: 'France',
countrycode: 'FR',
},
};
assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris);
assert.equal(selectQualifiedFeature([station], 'Paris, France'), null);
});
test('"UK" names the United Kingdom', () => {
const london = {
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
properties: {
osm_key: 'place',
name: 'London',
country: 'United Kingdom',
countrycode: 'GB',
},
};
const ontario = {
geometry: { type: 'Point', coordinates: [-81.25, 42.98] },
properties: {
osm_key: 'place',
name: 'London',
state: 'Ontario',
country: 'Canada',
countrycode: 'CA',
},
};
assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london);
});
test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => {
const store = (lon, lat, city) => ({
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
osm_key: 'shop',
osm_value: 'houseware',
name: 'Bed, Bath & Beyond',
city,
countrycode: 'US',
},
});
const nearby = store(-104.94, 39.66, 'Denver');
const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]);
const result = await geocodeKeyless('Bed, Bath & Beyond', {
bias: '39.60,-105.00|39.70,-104.90',
fetchImpl,
});
assert.match(result.label, /Denver/);
// Main answered this from the nearby pass alone. The second request is the
// accepted cost of checking the region unbiased first.
assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first');
});
test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => {
const place = (name, lon, lat, props) => ({
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { osm_key: 'place', name, ...props },
});
const nearby = [
place('Springfield IL Auto', -89.6, 39.8, {
osm_key: 'shop',
city: 'Springfield',
state: 'Illinois',
countrycode: 'US',
}),
];
const anywhere = [
place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }),
];
const fetchImpl = photonPair(nearby, anywhere);
const result = await geocodeKeyless('Springfield, IL', {
bias: '39.70,-89.70|39.90,-89.50',
fetchImpl,
});
assert.doesNotMatch(result.label, /Auto/);
assert.match(result.label, /^Springfield/);
});
test('a region query without a camera bias still takes the place in the region', async () => {
// Unbiased, Photon can list the pub first; main took it because its name
// starts with "Paris".
const fetchImpl = photonPair([], [PARIS_TEXAS_PUB, PARIS_TX]);
const result = await geocodeKeyless('Paris, Texas', { fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region is matched by whole words, and every word has to fit', () => {
const londonUk = {
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
properties: { osm_key: 'place', name: 'London', state: 'England', country: 'United Kingdom', countrycode: 'GB' },
};
// "on" is inside "london" and "england", but it is not a word of either.
assert.equal(selectQualifiedFeature([londonUk], 'London, ON'), null);
// "Texas" fits, "Ontario" does not, so the town does not qualify.
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Texas, Ontario'), null);
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Tex'), null);
});
test('a region can be the city or the county a candidate lies in', () => {
const street = {
geometry: { type: 'Point', coordinates: [-105.27, 40.02] },
properties: { osm_key: 'highway', name: 'Main Street', city: 'Boulder', countrycode: 'US' },
};
const town = {
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
properties: { osm_key: 'place', name: 'Paris', county: 'Lamar', countrycode: 'US' },
};
assert.equal(selectQualifiedFeature([street], 'Main Street, Boulder'), street);
assert.equal(selectQualifiedFeature([town], 'Paris, Lamar'), town);
});
test('when a POI and the place both fit the region, the place wins', async () => {
// A cafe named "Paris" in Lyon is in France too. Nearby it can rank above the
// city, and it would frame at rooftop range.
const cafe = {
geometry: { type: 'Point', coordinates: [4.84, 45.76] },
properties: { osm_key: 'amenity', osm_value: 'cafe', name: 'Paris', city: 'Lyon', country: 'France', countrycode: 'FR' },
};
const paris = {
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'Paris', state: 'Île-de-France', country: 'France', countrycode: 'FR' },
};
const fetchImpl = photonPair([cafe, paris], [paris]);
const result = await geocodeKeyless('Paris, France', { bias: '45.70,4.80|45.80,4.90', fetchImpl });
assert.equal(Math.round(result.lat), 49, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
// A POI alone still qualifies: "Main Street, Denver" names a street.
assert.equal(selectQualifiedFeature([cafe], 'Paris, France'), cafe);
});
test('if the unbiased request fails, the nearby whole-name match is still the answer', async () => {
const store = {
geometry: { type: 'Point', coordinates: [-104.94, 39.66] },
properties: { osm_key: 'shop', name: 'Bed, Bath & Beyond', city: 'Denver', countrycode: 'US' },
};
const urls = [];
const fetchImpl = (url) => {
urls.push(url);
if (!new URL(url).searchParams.has('lat')) return Promise.resolve({ ok: false, status: 503 });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [store] }) });
};
const outcome = await geocodeKeylessWithOutcome('Bed, Bath & Beyond', {
bias: '39.60,-105.00|39.70,-104.90',
fetchImpl,
cache: new Map(),
});
assert.match(outcome.place.label, /Denver/);
assert.equal(outcome.answered, true);
assert.equal(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');
@@ -453,7 +693,6 @@ function readGeocodingConsumer(file) {
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
}
test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => {
for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) {
const params = new URL(photonSearchUrl('island', { bias })).searchParams;