From acac1e4842d01878ef50082a3b6b73b84a5c0254 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 29 Aug 2026 15:50:25 +0700 Subject: [PATCH] fix(search): keep proximity bias from choosing a different place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photon scores proximity against the name, so a viewport bias could outrank an exact name match by distance alone. Searching "Hue" from Austin returned Hutto, Texas; "Ha Long" returned Long Branch. Measured against the live service over twelve Vietnamese place names, three resolved to the wrong continent. Google's `bounds` prefers a result inside the box without changing what qualifies as a result, and that is the semantics this adapter promised to reproduce. So a biased answer is now accepted only when a candidate leads with the name searched for, and the query otherwise re-runs unbiased — one extra request, on exactly the searches that were going to be wrong. Matching leads rather than contains, which is what separates "Hue" from "Nguyen Hue Road", and compares unaccented so "Hue" and "Huế" are one name. On the unbiased pass the head of a "place, region" query is matched first; the tail only says where to look. Nearby searches are unaffected: "Sixth Street" and "Barton Springs" over Austin still take the biased answer in a single request. --- src/keylessGeocoder.js | 99 +++++++++++++++++++++++++++++++----- src/keylessGeocoder.test.mjs | 93 ++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 15 deletions(-) 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), ''); +});