diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 9de6faea9..8b8b24e4a 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -26,6 +26,12 @@ const PHOTON_TIMEOUT_MS = 6000; /** Bounded memo — a search box re-issues the same query on every keystroke. */ const PHOTON_CACHE_MAX = 64; + +/** + * Candidates requested per pass. One is not enough: proximity bias reorders the + * list, so the name actually asked for can sit below a nearer near-miss. + */ +const PHOTON_RESULT_LIMIT = 5; const photonCache = new Map(); /** @@ -187,13 +193,13 @@ export function normalizePhotonFeature(feature) { * and to England's over London. Soft bias is the semantics Google gives, so * soft bias is what the adapter must produce. * @param {string} query - Free-text place name. - * @param {{bias?: ?string}} [options] + * @param {{bias?: ?string, limit?: number}} [options] * @returns {string} */ -export function photonSearchUrl(query, { bias = null } = {}) { +export function photonSearchUrl(query, { bias = null, limit = PHOTON_RESULT_LIMIT } = {}) { const url = new URL(PHOTON_ENDPOINT); url.searchParams.set('q', String(query ?? '')); - url.searchParams.set('limit', '1'); + url.searchParams.set('limit', String(limit)); const corners = String(bias ?? '').split('|'); if (corners.length === 2) { @@ -207,6 +213,49 @@ export function photonSearchUrl(query, { bias = null } = {}) { return url.toString(); } +/** + * Place name reduced to comparable form: unaccented, lowercased, punctuation + * collapsed to single spaces. "Huế" and "Hue" have to compare equal, because a + * user typing either means the same city. + * @param {string} text + * @returns {string} + */ +export function normalizeToponym(text) { + return String(text ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +/** + * Pick the candidate whose name actually answers `target`. + * + * Matching leads rather than merely contains: "Hue" must select `Huế` and not + * `Nguyen Hue Road`, which contains the word but names a different thing. + * `allowContains` relaxes that to a substring match, used only as a second + * choice once no candidate leads with the name. + * @param {object[]} features - Photon features, in the order returned. + * @param {string} target - Normalized name being looked for. + * @param {{allowContains?: boolean}} [options] + * @returns {?object} + */ +export function selectPhotonFeature(features, target, { allowContains = false } = {}) { + if (!target) return null; + const names = (Array.isArray(features) ? features : []) + .map((feature) => [feature, normalizeToponym(feature?.properties?.name)]); + + for (const [feature, name] of names) { + if (name === target || name.startsWith(`${target} `)) return feature; + } + if (!allowContains) return null; + for (const [feature, name] of names) { + if (name.includes(target)) return feature; + } + return null; +} + /** Evict the oldest memo entries until the cache is within its cap. */ function trimPhotonCache() { while (photonCache.size > PHOTON_CACHE_MAX) { @@ -228,23 +277,45 @@ export async function geocodeKeyless(query, { bias = null, fetchImpl = fetch } = const trimmed = String(query ?? '').trim(); if (!trimmed) return null; - const url = photonSearchUrl(trimmed, { bias }); - if (photonCache.has(url)) return photonCache.get(url); + const memoKey = `${bias ?? ''}\n${trimmed}`; + if (photonCache.has(memoKey)) return photonCache.get(memoKey); - let result = null; - try { - const response = await fetchImpl(url, { signal: AbortSignal.timeout(PHOTON_TIMEOUT_MS) }); - if (response.ok) { - const body = await response.json(); - result = normalizePhotonFeature(body?.features?.[0]); + const ask = async (url) => { + try { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(PHOTON_TIMEOUT_MS) }); + if (!response.ok) return null; + return (await response.json())?.features || []; + } catch { + return null; } - } catch { - result = null; + }; + + // Bias decides WHICH match wins, never WHAT counts as a match. Photon scores + // proximity against the name, so from Austin "Huế" came back as Hutto, Texas + // and "Hạ Long" as Long Branch — near misses beating the exact name by + // distance alone. Measured over twelve Vietnamese place names, three resolved + // 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. + let feature = null; + if (bias) { + feature = selectPhotonFeature(await ask(photonSearchUrl(trimmed, { bias })), normalizeToponym(trimmed)); } + if (!feature) { + const anywhere = await ask(photonSearchUrl(trimmed, { bias: null })); + // "Hoan Kiem Lake, Hanoi" names a place and then the region holding it, the + // convention every geocoder's free-text field follows. The head segment is + // the thing being searched for; the tail only says where to look. + const head = normalizeToponym(trimmed.split(',')[0]); + feature = selectPhotonFeature(anywhere, head, { allowContains: true }) || anywhere?.[0]; + } + + const result = normalizePhotonFeature(feature); + // Misses are cached too: a typo re-issued on every keystroke should cost one // request, not one per stroke. - photonCache.set(url, result); + photonCache.set(memoKey, result); trimPhotonCache(); return result; } diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 922acd9d0..b7e0a0078 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -20,10 +20,12 @@ import assert from 'node:assert/strict'; import { geocodeKeyless, normalizePhotonFeature, + normalizeToponym, photonExtentToBounds, photonResultLabel, photonResultTypes, photonSearchUrl, + selectPhotonFeature, } from './keylessGeocoder.js'; import { geocodeNavigationMode } from './locations.js'; @@ -130,7 +132,9 @@ test('the Google bias becomes a SOFT proximity bias, never a hard bbox', () => { const url = new URL(photonSearchUrl('Sixth Street', { bias: '30.2000,-97.8000|30.3000,-97.7000' })); assert.equal(url.searchParams.get('q'), 'Sixth Street'); - assert.equal(url.searchParams.get('limit'), '1'); + // Five, not one: proximity reorders the list, so the name actually asked for + // can sit below a nearer near-miss and must still be reachable. + assert.equal(url.searchParams.get('limit'), '5'); assert.equal(url.searchParams.get('lat'), '30.25', 'centre of the biased rectangle'); assert.equal(url.searchParams.get('lon'), '-97.75'); @@ -197,3 +201,90 @@ test('a feature without usable coordinates is rejected', () => { assert.equal(normalizePhotonFeature({ geometry: { coordinates: [200, 10] } }), null); assert.equal(normalizePhotonFeature({ geometry: { coordinates: [10, 91] } }), null); }); + +// ── Bias may choose among matches; it may not change what counts as one ────── + +/** Photon's real answers for "Huế": Austin-biased, then unbiased. */ +const HUTTO = { + geometry: { type: 'Point', coordinates: [-97.6842, 30.5427] }, + properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Hutto', country: 'United States' }, +}; +const HUE = { + geometry: { type: 'Point', coordinates: [107.5908, 16.4674] }, + properties: { + osm_key: 'place', osm_value: 'city', type: 'city', name: 'Huế', country: 'Việt Nam', + extent: [107.3763, 16.7739, 108.0518, 16.1276], + }, +}; + +/** Answers biased requests with `nearby`, unbiased ones with `anywhere`. */ +function photonPair(nearby, anywhere) { + const urls = []; + const impl = (url) => { + urls.push(url); + const biased = new URL(url).searchParams.has('lat'); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: biased ? nearby : anywhere }) }); + }; + impl.urls = urls; + return impl; +} + +test('a biased near-miss is refused, and the unbiased answer is taken instead', async () => { + // Measured from Austin against the live service: "Huế" scored Hutto, Texas + // above the city, and "Hạ Long" scored Long Branch. Distance alone must not + // outrank the name — three of twelve Vietnamese names crossed a continent. + const fetchImpl = photonPair([HUTTO], [HUE]); + + const result = await geocodeKeyless('Huế', { bias: '30.20,-97.80|30.30,-97.70', fetchImpl }); + + assert.equal(result.label.startsWith('Huế'), true, `got ${result.label}`); + assert.equal(Math.round(result.lat), 16); + assert.equal(fetchImpl.urls.length, 2, 'the refused bias costs exactly one extra request'); +}); + +test('a biased match that leads with the name is taken without a second request', async () => { + // The other half of the rule, and the reason bias exists at all: "Sixth + // Street" over Austin must stay Austin's. + const sixth = { + geometry: { type: 'Point', coordinates: [-97.7431, 30.2672] }, + properties: { osm_key: 'highway', osm_value: 'residential', type: 'street', name: 'Sixth Street', country: 'United States' }, + }; + const fetchImpl = photonPair([sixth], [HUE]); + + const result = await geocodeKeyless('Sixth Street', { bias: '30.20,-97.80|30.30,-97.70', fetchImpl }); + + assert.equal(result.label.startsWith('Sixth Street'), true); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a name is matched at its head, so a street that merely contains it loses', () => { + const road = { properties: { name: 'Nguyen Hue Road' } }; + const city = { properties: { name: 'Huế' } }; + + assert.equal(selectPhotonFeature([road, city], 'hue'), city); + // Relaxing to substring is a second choice only, never a first one. + assert.equal(selectPhotonFeature([road], 'hue'), null); + assert.equal(selectPhotonFeature([road], 'hue', { allowContains: true }), road); + assert.equal(selectPhotonFeature([city], ''), null); + assert.equal(selectPhotonFeature(undefined, 'hue'), null); +}); + +test('a "place, region" query is matched on the place, not the region', async () => { + const lake = { + geometry: { type: 'Point', coordinates: [105.8524, 21.0287] }, + properties: { osm_key: 'natural', osm_value: 'water', water: 'lake', name: 'Hoàn Kiếm Lake', country: 'Việt Nam' }, + }; + const street = { geometry: { type: 'Point', coordinates: [105.85, 21.03] }, properties: { name: 'Hoan Kiem District Road' } }; + const fetchImpl = photonPair([], [street, lake]); + + const result = await geocodeKeyless('Hoan Kiem Lake, Hanoi', { fetchImpl }); + + assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`); +}); + +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'); + assert.equal(normalizeToponym(" St. John's-Ravenscourt "), 'st john s ravenscourt'); + assert.equal(normalizeToponym(undefined), ''); +});