mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(geocode): prefer the settlement when a POI also fits the region
When several candidates fit a "place, region" query, take a settlement or administrative area over a POI of the same name, so a cafe called "Paris" in Lyon cannot win "Paris, France" from the nearby pass. Tests: region queries without a camera bias, whole-word and every-word region matching, city and county regions, the nearby whole-name answer when the unbiased request fails, and the settlement preference. The new tests sit with the other Paris, Texas tests. The station fixture uses its real city. CURRENT-STATE now describes the order the code applies.
This commit is contained in:
1 parent
81680c5a15
commit
7788168d73
3 files changed
+218
-121
No files matched your search
@@ -5268,11 +5268,12 @@ 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. A "place, region" query
|
||||
only accepts a candidate named exactly for the place whose city, county, state,
|
||||
country or country code (or USA/UK) contains the region; nearby results that
|
||||
fail that check are retried unbiased, and a nearby place whose whole name is the
|
||||
query (one with a comma in it) is the last choice. Invalid or wrapped bounds
|
||||
returned by providers
|
||||
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
|
||||
|
||||
+19
-4
@@ -284,6 +284,13 @@ export function selectPhotonFeature(
|
||||
/** 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(',');
|
||||
@@ -294,7 +301,8 @@ function queryRegion(query) {
|
||||
* 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.
|
||||
* 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.
|
||||
@@ -305,8 +313,8 @@ export function selectQualifiedFeature(features, query) {
|
||||
const head = normalizeToponym(headText);
|
||||
if (!head || !region) return null;
|
||||
const words = region.split(' ');
|
||||
return (
|
||||
(Array.isArray(features) ? features : []).find((feature) => {
|
||||
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
|
||||
@@ -323,7 +331,14 @@ export function selectQualifiedFeature(features, query) {
|
||||
].join(' '),
|
||||
)} `;
|
||||
return words.every((word) => where.includes(` ${word} `));
|
||||
}) ?? null
|
||||
},
|
||||
);
|
||||
return (
|
||||
qualified.find((feature) =>
|
||||
SETTLEMENT_OSM_KEYS.has(feature?.properties?.osm_key),
|
||||
) ??
|
||||
qualified[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+193
-112
@@ -335,6 +335,199 @@ test('a region nearby fits nothing, so the unbiased answer decides', async () =>
|
||||
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');
|
||||
@@ -500,91 +693,6 @@ function readGeocodingConsumer(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);
|
||||
});
|
||||
|
||||
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: 'Lyon',
|
||||
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/);
|
||||
assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first');
|
||||
});
|
||||
|
||||
|
||||
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;
|
||||
@@ -594,30 +702,3 @@ test('wrapped view bias stays near the antimeridian and malformed coordinates ar
|
||||
assert.equal(new URL(photonSearchUrl('island', { bias })).searchParams.has('lon'), false);
|
||||
}
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
Reference in new issue
Block a user