diff --git a/server/src/nest/maps/maps-index.helpers.ts b/server/src/nest/maps/maps-index.helpers.ts index 32800f422..3ecf3b11b 100644 --- a/server/src/nest/maps/maps-index.helpers.ts +++ b/server/src/nest/maps/maps-index.helpers.ts @@ -5,8 +5,9 @@ * the decision to make them is MapsService's. */ import { getCountryFromCoords } from '../../utils/country-borders'; -import { buildOsmDetails, interleave, parsePoiCategories, samePlace, toApiLang } from './maps.helpers'; +import { buildOsmDetails, mergeRanked, parsePoiCategories, samePlace, toApiLang, type RowView } from './maps.helpers'; import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client'; +import type { PhotonLead } from './providers/photon.mapping'; import { INDEX_MAX_CATEGORY_CHARS, INDEX_MAX_LIMIT, @@ -15,6 +16,7 @@ import { isOsmHit, osmPlaceId, toPlaceRecord, + type TrekOsmPlace, type TrekPlace, type TrekSearchHit, } from './trek-places.client'; @@ -339,16 +341,50 @@ type Suggestion = MapsAutocompleteResult['suggestions'][number]; /** * The index's suggestions with Photon's interleaved, the way the search list - * merges the index with Nominatim (#2877). A Photon row the list already holds - * is dropped: the same OpenStreetMap element under the layer's copy of its id, - * or the same place by the rule the search list dedupes with. + * merges the index with Photon (#2877), through the same merge + * (`mergeRanked`): Photon's lead first, a row the list already holds once. + * A town leads only within `LEAD_TOWN_REACH_M` of where the traveller looks, + * so "Hamburg" asked in a Japan trip still opens on what the index found in + * Japan. */ -export function mergeSuggestions(fromIndex: Suggestion[], fromPhoton: Suggestion[], limit = 8): Suggestion[] { - const asPlace = (s: Suggestion) => ({ lat: s.lat, lng: s.lng, name: s.mainText }); - const extra = fromPhoton.filter( - (o) => !fromIndex.some((i) => i.placeId === o.placeId || samePlace(asPlace(i), asPlace(o))), - ); - return interleave(fromIndex, extra, limit); +export function mergeSuggestions( + fromIndex: Suggestion[], + fromPhoton: Suggestion[], + limit = 8, + opts: { lead?: PhotonLead | null; bias?: { lat: number; lng: number } } = {}, +): Suggestion[] { + return mergeRanked(fromIndex, fromPhoton, limit, suggestionView, opts); +} + +const suggestionView = (s: Suggestion): RowView => ({ + id: s.placeId, + name: s.mainText, + line: s.secondaryText, + lat: s.lat, + lng: s.lng, +}); + +/** + * A row of the index's OpenStreetMap layer in the shape a search answers + * with. The layer carries no address, so the line is the town it names and + * the country of its coordinate, the way its suggestion row reads. + */ +export function osmLayerRecord(p: TrekOsmPlace, lang?: string): Record { + return { + google_place_id: null, + google_ftid: null, + osm_id: osmPlaceId(p), + name: p.name, + address: [p.locality, countryName(getCountryFromCoords(p.lat, p.lng), lang)].filter(Boolean).join(', '), + lat: Number.isFinite(p.lat) ? p.lat : null, + lng: Number.isFinite(p.lng) ? p.lng : null, + rating: null, + website: null, + phone: null, + // The layer's categories are TREK's own segments, not OpenStreetMap values. + category: null, + source: 'openstreetmap', + }; } /** How long Photon's suggestions may still take once the index has answered. */ diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts index 8fb7a7ec7..2b6ac58d1 100644 --- a/server/src/nest/maps/maps.helpers.ts +++ b/server/src/nest/maps/maps.helpers.ts @@ -2,6 +2,7 @@ import { readEnv, getAppUrl } from '../../app-config'; import { haversineMetres } from '../common/geo'; import { stripHtmlTags } from '../common/stripHtmlTags'; import { buildUserAgent, parseOverpassEndpoints } from './overpass.constants'; +import type { PhotonLead } from './providers/photon.mapping'; import { normalizePlaceWebsite } from '@trek/shared'; import { z } from 'zod'; @@ -113,9 +114,97 @@ export function mergeSearchResults( fromIndex: Record[], fromOsm: Record[], limit = 10, + opts: { lead?: PhotonLead | null; bias?: { lat: number; lng: number } } = {}, ): Record[] { - const extra = fromOsm.filter((o) => !fromIndex.some((i) => samePlace(i, o))); - return interleave(fromIndex, extra, limit); + return mergeRanked(fromIndex, fromOsm, limit, recordView, opts); +} + +/** What the merge reads of a row: its id, its name, the line below it and where it is. */ +export interface RowView { + id: string | null; + name: string; + line: string; + lat?: number; + lng?: number; +} + +const recordView = (r: Record): RowView => ({ + id: typeof r.osm_id === 'string' ? r.osm_id : null, + name: String(r.name ?? ''), + line: String(r.address ?? ''), + lat: typeof r.lat === 'number' ? r.lat : undefined, + lng: typeof r.lng === 'number' ? r.lng : undefined, +}); + +/** + * The one merge behind the search list and the suggestion list, so the two + * agree on what comes first. The second list's rows the first already holds + * go (the same id, or the same place by `samePlace`), the rest alternate with + * the first's, the first leading. + * + * Then Photon's lead (`pickLead`) goes first, and rows of its name inside it + * go: "Berlin" no longer opens on a business filed as Berlin above the city. + * Rows that read the same twice, two branches of a chain with the same town + * below them, are one row: nobody can tell them apart in the list. + */ +export function mergeRanked( + first: T[], + second: T[], + limit: number, + view: (row: T) => RowView, + { lead = null, bias }: { lead?: PhotonLead | null; bias?: { lat: number; lng: number } } = {}, +): T[] { + const asPlace = (row: T) => { + const v = view(row); + return { lat: v.lat, lng: v.lng, name: v.name }; + }; + const extra = second.filter((o) => { + const id = view(o).id; + return !first.some((i) => (id !== null && view(i).id === id) || samePlace(asPlace(i), asPlace(o))); + }); + const merged = interleave(first, extra, Number.POSITIVE_INFINITY); + const top = lead && leadsHere(lead, bias) ? merged.find((row) => standsFor(view(row), lead)) : undefined; + const ordered = top && lead ? [top, ...merged.filter((row) => row !== top && !standsFor(view(row), lead))] : merged; + const shown = new Set(); + return ordered + .filter((row) => { + const v = view(row); + const reads = `${foldName(v.name)}\n${foldName(v.line)}`; + if (shown.has(reads)) return false; + shown.add(reads); + return true; + }) + .slice(0, limit); +} + +/** How far from where the traveller looks a town may lie and still be what its name means there. */ +export const LEAD_TOWN_REACH_M = 1_000_000; + +/** What may lead with nothing to say where the traveller is looking: a town, an island or a sight, not a municipality or a county. */ +const UNBIASED_LEADS = new Set(['city', 'town', 'landmark', 'island', 'archipelago', 'airport']); + +/** Whether a lead leads from where the traveller is looking. */ +export function leadsHere(lead: PhotonLead, bias: { lat: number; lng: number } | undefined): boolean { + if (lead.reach === 'everywhere') return true; + if (!bias) return UNBIASED_LEADS.has(lead.kind); + return haversineMetres(bias.lat, bias.lng, lead.lat, lead.lng) <= LEAD_TOWN_REACH_M; +} + +/** Whether a row is the lead itself, under its id or as a row of its name inside it. */ +function standsFor(row: RowView, lead: PhotonLead): boolean { + if (row.id === lead.placeId) return true; + if (row.lat === undefined || row.lng === undefined || foldName(row.name) !== foldName(lead.name)) return false; + if (!lead.extent) return haversineMetres(row.lat, row.lng, lead.lat, lead.lng) < LEAD_WITHOUT_EXTENT_M; + const [west, north, east, south] = lead.extent; + return row.lng >= west && row.lng <= east && row.lat >= south && row.lat <= north; +} + +/** Around a lead Photon gave no extent for, how near a row of its name has to be to be it. */ +const LEAD_WITHOUT_EXTENT_M = 25_000; + +/** A name as the merge compares it: without accents, case or extra spaces. */ +export function foldName(value: string): string { + return value.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/\s+/gu, ' ').trim(); } /** One row of each list in turn, the first list leading, up to `limit` rows. */ diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 1e252bd97..133fd789a 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -13,6 +13,7 @@ import { indexSuggestions, mergeIndexPages, mergeSuggestions, + osmLayerRecord, PHOTON_GRACE_MS, } from './maps-index.helpers'; import { NEARBY_DEFAULT_LIMIT, NEARBY_DEFAULT_RADIUS_M, nearbyCacheKey, nearestFirst } from './maps-nearby.helpers'; @@ -29,12 +30,11 @@ import type { PlacesProviderChoice } from './providers/places-provider'; import { WikimediaClient, type BrandLogo } from './providers/wikimedia.client'; import { trekPlacesSearch, - indexHitsOnly, + isOsmHit, trekPlacesArea, trekPlacesNearby, toPlaceRecord, type TrekHighlight, - type TrekPlace, } from './trek-places.client'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable } from '@nestjs/common'; @@ -304,7 +304,7 @@ export class MapsService { * a Nominatim call on the background lane, waiting out its throttle. The index * answers most of them without leaving our own infrastructure and without a * throttle at all; a street address it does not know still falls through to - * Nominatim, which is the source that resolves addresses. + * Photon, which resolves addresses (Nominatim without the TREK services). * * Never throws: an import that cannot place a hotel still imports the hotel. */ @@ -380,38 +380,47 @@ export class MapsService { !(await this.selector.googleOnly(keyed, opts.googleOnly)) ) { // Both at once. The index is a dataset of businesses and is very good - // at those; OpenStreetMap is where the temples, bridges, riverside - // walks and viewpoints are, and a travel search asks for those - // constantly. Concurrently, so the pair costs the slower one rather - // than the sum. This is the explicit search, not the keystroke path - // Nominatim's policy rules out. + // at those; its OpenStreetMap layer and Photon are where the temples, + // bridges, riverside walks, viewpoints and towns are, and a travel search + // asks for those constantly. Concurrently, so the pair costs the slower + // one rather than the sum. The list is merged the way the suggestions + // are (`mergeRanked`), Photon's lead first, so the search and the + // suggestions agree on what a name means. // // Each side catches its own failure. A rejection reaching Promise.all // would throw away the answer the other side had already produced — and // the index refusing a query is ordinary traffic, not an outage: a common // single word without coordinates is turned down upstream as too - // expensive. That used to discard ten good OpenStreetMap results and ask - // Nominatim the same question a second time, behind its own 1.1 s - // process-wide throttle. + // expensive. const [found, osm] = await Promise.all([ trekPlacesSearch(query, { lat: locationBias?.lat, lng: locationBias?.lng, limit: 10, + sources: 'index,osm', lang, - }) - .then(indexHitsOnly) - .catch((err: unknown) => { - console.warn('TREK Places search failed, falling back:', (err as Error).message); - return [] as TrekPlace[]; - }), - this.osm.searchNominatim(query, lang, 'interactive', locationBias).catch((err: unknown) => { - console.warn('OpenStreetMap search failed, index only:', (err as Error).message); - return [] as Record[]; + }).catch((err: unknown) => { + console.warn('TREK Places search failed, falling back:', (err as Error).message); + return []; }), + this.osm + .searchPhoton(query, lang, locationBias) + // Nominatim only for an installation Photon does not answer. + .then( + async (photon) => + photon ?? { + records: await this.osm.searchNominatim(query, lang, 'interactive', locationBias), + lead: null, + }, + ) + .catch((err: unknown) => { + console.warn('OpenStreetMap search failed, index only:', (err as Error).message); + return { records: [] as Record[], lead: null }; + }), ]); - osmAnswer = osm; - const places = mergeSearchResults(found.map(toPlaceRecord), osm); + osmAnswer = osm.records; + const fromIndex = found.map((hit) => (isOsmHit(hit) ? osmLayerRecord(hit, lang) : toPlaceRecord(hit))); + const places = mergeSearchResults(fromIndex, osm.records, 10, { lead: osm.lead, bias: locationBias }); if (places.length > 0) { // Names the sources that actually contributed, not the ones that were // asked. Either side can come back empty — the index turns down a common @@ -420,7 +429,7 @@ export class MapsService { // scored against, so a list that is entirely OpenStreetMap must not be // recorded as though the index had a hand in it. const source = - found.length > 0 ? (osm.length > 0 ? 'trek-places+openstreetmap' : 'trek-places') : 'openstreetmap'; + found.length > 0 ? (osm.records.length > 0 ? 'trek-places+openstreetmap' : 'trek-places') : 'openstreetmap'; return { places, source }; } } @@ -565,7 +574,7 @@ export class MapsService { // answered it gets a moment more and is then dropped: a slow Photon must // not hold back what the index already has. const typing = new AbortController(); - const asked = this.osm.autocompletePhoton(input, lang, boxCentre, typing.signal); + const asked = this.osm.suggestPhoton(input, lang, boxCentre, typing.signal); const found = await trekPlacesSearch(input, { lat: boxCentre?.lat, lng: boxCentre?.lng, @@ -578,7 +587,10 @@ export class MapsService { }); const photon = found.length > 0 ? await answerWithin(asked, PHOTON_GRACE_MS) : await asked; typing.abort(); - const suggestions = mergeSuggestions(indexSuggestions(found, lang), photon ?? []); + const suggestions = mergeSuggestions(indexSuggestions(found, lang), photon?.rows ?? [], 8, { + lead: photon?.lead, + bias: boxCentre, + }); if (suggestions.length > 0) { return { suggestions, source: found.length > 0 ? 'trek-places' : 'openstreetmap' }; } diff --git a/server/src/nest/maps/providers/osm.client.ts b/server/src/nest/maps/providers/osm.client.ts index 28a903fee..826e36c27 100644 --- a/server/src/nest/maps/providers/osm.client.ts +++ b/server/src/nest/maps/providers/osm.client.ts @@ -24,6 +24,7 @@ * Never imports MapsService: providers sit below the orchestrator * (lint:boundaries holds that). */ +import { findByIata, type Airport } from '../../airports/airports.data'; import { logWarn } from '../../audit/audit-log.logger'; import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client'; import { OVERPASS_ALONG_QUERY_TIMEOUT_S, aroundPath, overpassAlongQuery } from '../maps-along.helpers'; @@ -48,7 +49,16 @@ import { import { overpassSelector, tagsMatchFilter } from '../osm-filter.helpers'; import { OverpassBusy, hedged, lastAnsweredFirst, retryAfterMs, soonestRetryMs } from '../overpass-hedge.helpers'; import { PhotonClient } from './photon.client'; -import { asNominatimHit, asNominatimReverse, asSuggestion } from './photon.mapping'; +import { + asNominatimHit, + asNominatimReverse, + asSuggestion, + photonDisplayName, + pickLead, + rankFeatures, + type PhotonFeature, + type PhotonLead, +} from './photon.mapping'; import { readWikiIdentity } from './wiki-identity'; import { Injectable } from '@nestjs/common'; import { normalizePlaceWebsite } from '@trek/shared'; @@ -74,6 +84,10 @@ export interface NominatimResult { export interface OverpassElement { tags?: Record; + /** Asked for with `located`: a node's coordinate, or the centre of a way or relation. */ + lat?: number; + lon?: number; + center?: { lat: number; lon: number }; } interface OverpassPoiElement { @@ -136,12 +150,66 @@ function isHit(hit: NominatimResult | null): hit is NominatimResult { return hit !== null; } +/** + * The airport a code in capitals names ("JFK", "LHR"), from the bundled + * dataset. Photon does not search codes: "JFK" answered with an expressway, + * "FRA" with France. So the code is asked for as the airport's name at its + * coordinate, where the aerodrome comes first. Lower case is left alone, so + * "Ber" typed on the way to "Berlin" stays a search for Berlin. + */ +function airportOfCode(query: string): Airport | null { + const code = query.trim(); + return /^[A-Z]{3}$/.test(code) ? findByIata(code) : null; +} + +/** + * The aerodrome of a code's answer, within a few kilometres of the airport, as + * a lead from everywhere; failing that a place there that calls itself an + * airport, which is how OpenStreetMap files Haneda (an island named "Tokyo + * International Airport"). + */ +function airportLead(ranked: PhotonFeature[], airport: Airport): PhotonLead | null { + const near = ranked + .map((feature) => ({ feature, row: asSuggestion(feature) })) + .filter( + ( + c, + ): c is { + feature: PhotonFeature; + row: NonNullable> & { lat: number; lng: number }; + } => + c.row?.lat !== undefined && + c.row.lng !== undefined && + haversineMetres(airport.lat, airport.lng, c.row.lat, c.row.lng) <= AIRPORT_MATCH_M, + ); + const best = + near.find((c) => c.feature.properties.osm_value === 'aerodrome') ?? + near.find((c) => c.feature.properties.osm_key === 'place' && AIRPORT_WORDS.test(c.row.mainText)); + if (!best) return null; + const { placeId, mainText, lat, lng } = best.row; + return { placeId, name: mainText, reach: 'everywhere', kind: 'airport', lat, lng, extent: null }; +} + +const AIRPORT_WORDS = /airport|flughafen|aéroport|aeropuerto|aeroporto|luchthaven|lotnisko|空港|机场|공항/iu; + +/** How far an aerodrome may lie from the dataset's coordinate of its airport and still be it. */ +const AIRPORT_MATCH_M = 8000; + // Tighter than the wiki calls, because this one sits at the FRONT of a chain: // identity, then sitelinks, then the extract. Nominatim answers a bounded // search in 0.2-0.7s in practice, so anything past a couple of seconds is a bad // day at the provider rather than a slow answer worth waiting for. const IDENTITY_TIMEOUT_MS = 2500; +// A station or a road named after a place is not the place, unless the name +// asked for one ("Hauptbahnhof", "Tor station"). +const TRANSPORT_KEYS = new Set(['railway', 'public_transport', 'highway']); +const TRANSPORT_WORDS = + /(station|bahnhof|hbf|bhf|halt|haltestelle|stop|street|straße|strasse|road|weg|gare|estación|stazione|駅)/iu; + +// How far a reverse lookup that found nothing next to the point looks again. +const WIDE_REVERSE_KM = 50; + // The explicit search asks Nominatim alongside the index, and the pair costs // the slower one. The index gives up after 3.5 s and the browser after 8 s, so a // Nominatim that accepts the connection and then sits on it (the public service @@ -444,19 +512,37 @@ export class OsmClient { // ── Nominatim search ─────────────────────────────────────────────────────── /** - * `lane` defaults to interactive because most callers are a keystroke. + * Photon's search, its hits ranked (`rankFeatures`) and read as search + * records, with the lead the list opens on; or null where Photon is not in + * use or did not answer, and the caller is left to what it had before. + */ + async searchPhoton(query: string, lang?: string, bias?: { lat: number; lng: number }) { + const airport = airportOfCode(query); + const found = await this.photon.search(airport?.name ?? query, { lang, bias: airport ?? bias }); + if (!found) return null; + const ranked = rankFeatures(found); + return { + records: ranked.map(asNominatimHit).filter(isHit).map(searchRecord), + lead: airport ? airportLead(ranked, airport) : pickLead(ranked, query, bias), + }; + } + + /** + * Photon's answer where the installation has it, Nominatim's otherwise. * + * Photon answers whenever it is in use, an empty answer and a miss + * included: Nominatim is the search of an installation without the TREK + * services (or with an operator's own Nominatim), not a second opinion or a + * stand-in behind Photon. Asked + * beside it, every miss of Photon took a 1.1 s slot of the process-wide + * throttle, and a few searches at once queued for seconds. + * + * `lane` defaults to interactive because most callers are a keystroke. * Bulk callers must pass 'background': booking-import geocodes every venue and * every uncoordinated endpoint of an import in one request loop, which is up * to thirty sequential calls. On the interactive lane those thirty take the - * next slot each time, so somebody typing in the place search waits behind the - * whole import. Yielding does not make the import faster, it stops it from - * being the only thing the process will do for half a minute. That loop - * asks Nominatim alone, so an import does not spend the Photon allowance - * the people typing need. - * - * Photon matches differently and can find nothing where Nominatim finds the - * place, so an answer without a usable hit is asked again there. + * next Nominatim slot each time, so somebody typing in the place search waits + * behind the whole import. */ async searchNominatim( query: string, @@ -464,9 +550,11 @@ export class OsmClient { lane: GeoLane = 'interactive', bias?: { lat: number; lng: number }, ) { - const photon = lane === 'background' ? null : await this.photon.search(query, { lang, bias }); - const hits = photon?.map(asNominatimHit).filter(isHit); - if (hits?.length) return hits.map(searchRecord); + const photon = await this.searchPhoton(query, lang, bias); + if (photon) return photon.records; + // Photon is this installation's geocoder and missed this one (an error, a + // timeout, a spent allowance): the answer is empty, not Nominatim's. + if (this.photon.inUse()) return []; const params = new URLSearchParams({ q: query, format: 'json', @@ -533,8 +621,19 @@ export class OsmClient { * waits for any more. */ async autocompletePhoton(input: string, lang?: string, bias?: { lat: number; lng: number }, signal?: AbortSignal) { - const found = await this.photon.suggest(input, { lang, bias, signal }); - return found?.map(asSuggestion).filter((row) => row !== null) ?? null; + return (await this.suggestPhoton(input, lang, bias, signal))?.rows ?? null; + } + + /** The same suggestions with Photon's first hit read as a lead, for the list that merges them with the index. */ + async suggestPhoton(input: string, lang?: string, bias?: { lat: number; lng: number }, signal?: AbortSignal) { + const airport = airportOfCode(input); + const found = await this.photon.suggest(airport?.name ?? input, { lang, bias: airport ?? bias, signal }); + if (!found) return null; + const ranked = rankFeatures(found); + return { + rows: ranked.map(asSuggestion).filter((row) => row !== null), + lead: airport ? airportLead(ranked, airport) : pickLead(ranked, input, bias), + }; } /** @@ -564,6 +663,7 @@ export class OsmClient { const query = (name || '').trim(); if (!query || !Number.isFinite(lat) || !Number.isFinite(lng)) return null; const maxDistanceM = opts.maxDistanceM ?? 2000; + if (this.photon.inUse()) return this.photonIdentity(query, lat, lng, maxDistanceM, opts.lang); // ~2km box around the point, so Nominatim ranks locally instead of handing // back the most famous place on earth with this name. @@ -622,6 +722,43 @@ export class OsmClient { } } + /** + * The identity from Photon, inside the same box and through the same two + * gates. Photon has no `importance`, so a Wikidata link stands in for it, + * and a station or a road named after the place steps back unless the name + * asked for one: the Brandenburg Gate before the station beside it. + * Measured on eight landmarks: seven right, against five in Photon's order. + */ + private async photonIdentity( + query: string, + lat: number, + lng: number, + maxDistanceM: number, + lang?: string, + ): Promise<{ tags: Record; osmUrl: string | null; matchedName: string } | null> { + const d = 0.02; + const found = await this.photon.search(query, { lang, bbox: [lng - d, lat - d, lng + d, lat + d], limit: 5 }); + const transport = !TRANSPORT_WORDS.test(query); + const best = (found ?? []) + .map((feature) => ({ feature, hit: asNominatimHit(feature) })) + .filter((c): c is { feature: PhotonFeature; hit: NominatimResult } => c.hit !== null) + .map((c) => ({ ...c, metres: haversineMetres(lat, lng, Number(c.hit.lat), Number(c.hit.lon)) })) + .filter((c) => c.metres <= maxDistanceM && namesOverlap(query, c.hit.name || '')) + .map((c) => ({ + ...c, + score: + (c.hit.extratags?.wikidata ? -2 : 0) + + (transport && TRANSPORT_KEYS.has(c.feature.properties.osm_key ?? '') ? 3 : 0), + })) + .sort((a, b) => a.score - b.score || a.metres - b.metres)[0]; + if (!best) return null; + return { + tags: best.hit.extratags ?? {}, + osmUrl: `https://www.openstreetmap.org/${best.hit.osm_type}/${best.hit.osm_id}`, + matchedName: best.hit.name || query, + }; + } + // ── Nominatim lookup (by OSM ID) ─────────────────────────────────────────── async lookupNominatim( @@ -673,8 +810,9 @@ export class OsmClient { lang?: string, opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean }, ): Promise<{ name: string | null; address: string | null }> { - const photon = opts?.lane === 'background' ? null : await this.reversePhoton(lat, lng, lang, opts?.timeoutMs); - const data = photon ?? (await this.reverseNominatim(lat, lng, lang, opts)); + const data = this.photon.inUse() + ? await this.reversePhoton(lat, lng, lang, opts?.timeoutMs) + : await this.reverseNominatim(lat, lng, lang, opts); if (!data) return { name: null, address: null }; const addr = data.address || {}; const name = opts?.locality @@ -684,9 +822,10 @@ export class OsmClient { } /** - * Photon's nearest feature, or null for Nominatim to answer. Photon looks - * within a radius and can find nothing at all, where Nominatim names the - * nearest object at any distance, so an empty answer is asked again there. + * Photon's nearest feature, or null where it has none. Photon looks within + * a small radius by default and finds nothing in open country or at sea, + * where Nominatim named the nearest object at any distance, so an empty + * answer is asked once more with a radius of 50 km. */ private async reversePhoton( lat: string, @@ -694,7 +833,10 @@ export class OsmClient { lang?: string, timeoutMs?: number, ): Promise { - const found = await this.photon.reverse(Number.parseFloat(lat), Number.parseFloat(lng), lang, timeoutMs); + const [y, x] = [Number.parseFloat(lat), Number.parseFloat(lng)]; + const near = await this.photon.reverse(y, x, lang, timeoutMs); + const found = + near?.length === 0 ? await this.photon.reverse(y, x, lang, timeoutMs, { radius: WIDE_REVERSE_KM }) : near; return found?.[0] ? asNominatimReverse(found[0]) : null; } @@ -723,8 +865,7 @@ export class OsmClient { * coordinates are already known, so the caller still has something to return. */ async reverseRaw(lat: number, lng: number): Promise { - const found = await this.photon.reverse(lat, lng); - if (found?.[0]) return asNominatimReverse(found[0]); + if (this.photon.inUse()) return (await this.reversePhoton(String(lat), String(lng))) ?? {}; const res = await nominatimFetch( 'reverse', new URLSearchParams({ lat: String(lat), lon: String(lng), format: 'json', addressdetails: '1' }), @@ -746,10 +887,17 @@ export class OsmClient { // sent as a query of its own. if (!OSM_PLACE_ID.test(placeId)) return null; const [osmType, osmId] = placeId.split(':'); + // Where Photon is the geocoder, Overpass places the element and Photon + // names it; Nominatim is asked only when Overpass could not place it. + const located = this.photon.inUse() ? await this.fetchOverpassDetails(osmType, osmId, true) : undefined; + if (located) { + const answer = await this.photonElementDetails(located, osmType, osmId, placeId, lang); + if (answer) return answer; + } // buildOsmDetails never yields name/address/coordinates — Nominatim is - // always the source for those (Overpass contributes the tag-derived rest). + // the source for those (Overpass contributes the tag-derived rest). const [element, nominatim] = await Promise.all([ - this.fetchOverpassDetails(osmType, osmId), + located === undefined ? this.fetchOverpassDetails(osmType, osmId) : located, this.lookupNominatim(osmType, osmId, lang), ]); // Overpass has the fuller tag set and wins where both answer, but it is @@ -769,9 +917,39 @@ export class OsmClient { }; } + /** + * The details of an element without Nominatim: Overpass gave the tags and + * where the element is, Photon's reverse lookup at that point names it and + * writes its address, the element itself where Photon has it. Null when + * Overpass did not say where it is, so the caller can still try Nominatim. + */ + private async photonElementDetails( + element: OverpassElement, + osmType: string, + osmId: string, + placeId: string, + lang?: string, + ): Promise | null> { + const at = element.center ?? (element.lat !== undefined && element.lon !== undefined ? element : null); + if (!at) return null; + const near = await this.photon.reverse(at.lat, at.lon, lang, undefined, { limit: 10, radius: 0.3 }); + const self = near?.find((f) => asNominatimHit(f)?.osm_type === osmType && String(f.properties.osm_id) === osmId); + const base = lang ? toApiLang(lang).split('-')[0] : ''; + const extra = self ? (asNominatimHit(self)?.extratags ?? {}) : {}; + return { + ...buildOsmDetails({ ...extra, ...(element.tags ?? {}) }, osmType, osmId), + name: (base && element.tags?.[`name:${base}`]) || self?.properties.name || element.tags?.name || '', + address: self ? photonDisplayName(self.properties) : near?.[0] ? photonDisplayName(near[0].properties) : '', + lat: at.lat, + lng: at.lon, + osm_id: placeId, + }; + } + // ── Overpass API (OSM details) ───────────────────────────────────────────── - async fetchOverpassDetails(osmType: string, osmId: string): Promise { + /** `located` asks for the element's coordinate as well: a node's own, the centre of a way or relation. */ + async fetchOverpassDetails(osmType: string, osmId: string, located = false): Promise { const typeMap: Record = { node: 'node', way: 'way', relation: 'rel' }; const oType = typeMap[osmType]; if (!oType) return null; @@ -779,7 +957,8 @@ export class OsmClient { // language. An OSM element id is a number and nothing else; anything with // more in it is a statement of its own, sent under TREK's shared user agent. if (!/^\d+$/.test(osmId)) return null; - const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`; + const out = !located ? 'out tags;' : oType === 'node' ? 'out;' : 'out tags center;'; + const query = `[out:json][timeout:5];${oType}(${osmId});${out}`; try { const res = await fetch('https://overpass-api.de/api/interpreter', { method: 'POST', diff --git a/server/src/nest/maps/providers/photon.client.ts b/server/src/nest/maps/providers/photon.client.ts index dd6d6248b..d58c9bb72 100644 --- a/server/src/nest/maps/providers/photon.client.ts +++ b/server/src/nest/maps/providers/photon.client.ts @@ -45,10 +45,13 @@ const SUGGEST_TIMEOUT_MS = 1500; const MAX_BYTES = 512_000; const SEARCH_LIMIT = 10; /** - * How far the bias reaches. Nominatim is handed a box of a degree around the - * point; zoom 10 is Photon's spelling of about that. + * How narrow the bias is. Asked from a city view ("Hauptbahnhof" from + * Hamburg, "Starbucks" from Tokyo), zoom 10 put the right place first 59 % of + * the time, 12 82 %, 13 91 % and 14 86 %. A wide decay lifts a namesake 50 km + * off above the real place; a narrow one leaves far places in Photon's own + * order of importance. */ -const BIAS_ZOOM = 10; +const BIAS_ZOOM = 13; const MISSES_BEFORE_PAUSE = 4; const PAUSE_MS = 60_000; /** The longest Retry-After followed; the first question after it finds out whether the allowance is back. */ @@ -57,6 +60,9 @@ const MAX_PAUSE_MS = 60 * 60_000; interface PhotonSearchOptions { lang?: string; bias?: { lat: number; lng: number }; + /** Only hits inside [west, south, east, north]. */ + bbox?: [number, number, number, number]; + limit?: number; } /** The misses in a row and the pause of one kind of question. */ @@ -90,15 +96,40 @@ export class PhotonClient { /** * What lies at a point, nearest first, or null when Nominatim has to answer. - * A caller's own deadline leaves Nominatim at least half of it. + * A caller's own deadline leaves Nominatim at least half of it. `radius` is + * Photon's search radius in km; its default finds nothing in open country, + * where Nominatim names the nearest object at any distance. */ - reverse(lat: number, lng: number, lang?: string, timeoutMs?: number): Promise { + reverse( + lat: number, + lng: number, + lang?: string, + timeoutMs?: number, + opts: { limit?: number; radius?: number } = {}, + ): Promise { if (!Number.isFinite(lat) || !Number.isFinite(lng)) return Promise.resolve(null); - const params = new URLSearchParams({ lat: String(lat), lon: String(lng), limit: '1', lang: photonLang(lang) }); + const params = new URLSearchParams({ + lat: String(lat), + lon: String(lng), + limit: String(opts.limit ?? 1), + lang: photonLang(lang), + }); + if (opts.radius) params.set('radius', String(opts.radius)); const deadline = timeoutMs ? Math.min(TIMEOUT_MS, timeoutMs / 2) : TIMEOUT_MS; return this.ask('reverse', params, this.lookups, deadline); } + /** + * Whether Photon is this installation's geocoder: the services are on, no + * operator's own Nominatim stands beside it, and there is a key to send. A + * question it then misses is a miss, not a reason to ask Nominatim. + */ + inUse(): boolean { + if (this.config.mode === 'off' || readEnv().integrations.nominatimOwn) return false; + const url = new URL(this.config.photonUrl); + return !!serviceIdentitySlot.get().headersFor(url).Authorization; + } + private async ask( path: 'api' | 'reverse', params: URLSearchParams, @@ -167,7 +198,12 @@ export class PhotonClient { } function searchParams(query: string, opts: PhotonSearchOptions): URLSearchParams { - const params = new URLSearchParams({ q: query, limit: String(SEARCH_LIMIT), lang: photonLang(opts.lang) }); + const params = new URLSearchParams({ + q: query, + limit: String(opts.limit ?? SEARCH_LIMIT), + lang: photonLang(opts.lang), + }); + if (opts.bbox) params.set('bbox', opts.bbox.join(',')); if (opts.bias && Number.isFinite(opts.bias.lat) && Number.isFinite(opts.bias.lng)) { params.set('lat', String(opts.bias.lat)); params.set('lon', String(opts.bias.lng)); diff --git a/server/src/nest/maps/providers/photon.mapping.ts b/server/src/nest/maps/providers/photon.mapping.ts index a6cea4ba6..d5f345f12 100644 --- a/server/src/nest/maps/providers/photon.mapping.ts +++ b/server/src/nest/maps/providers/photon.mapping.ts @@ -8,7 +8,7 @@ * the address as one comma-joined line. The OsmClient keeps one mapping from * Nominatim's shape to TREK's place record, and this file feeds it. */ -import { toApiLang } from '../maps.helpers'; +import { foldName, leadsHere, toApiLang } from '../maps.helpers'; import type { NominatimResult, NominatimReverse } from './osm.client'; import type { MapsAutocompleteResult } from '@trek/shared'; @@ -68,6 +68,8 @@ const photonFeatureSchema = z.object({ country: text, countrycode: text, extra: z.record(z.string(), z.unknown()).optional(), + // [west, north, east, south], on areas only. + extent: z.array(z.number()).length(4).optional(), }), }); @@ -144,24 +146,211 @@ export function asSuggestion(feature: PhotonFeature): Suggestion | null { const hit = asNominatimHit(feature); const [lng, lat] = feature.geometry.coordinates; if (!hit || !Number.isFinite(lat) || !Number.isFinite(lng)) return null; - // The address line starts with the name, so the rest of it reads as where the - // place is. Cut by the name rather than at its first comma, which a name can - // hold; a house without a name is titled by its number, as Nominatim does. + // A house without a name is titled by its number, as Nominatim does, and + // the rest of the line says where it is. const name = feature.properties.name?.trim(); - const line = hit.display_name ?? ''; - const [head = '', ...rest] = line.split(', '); + const [head = '', ...rest] = suggestionLine(feature.properties); const mainText = name || head; if (!mainText) return null; return { placeId: `${hit.osm_type}:${hit.osm_id}`, mainText, - secondaryText: name ? line.slice(name.length).replace(/^, /, '') : rest.join(', '), + secondaryText: (name ? [head, ...rest] : rest).filter(Boolean).join(', '), source: 'openstreetmap', lat, lng, }; } +/** + * Where a suggestion is, below its name: house number and street, quarter, + * town and country, and the region only where there is no town. Shorter than + * the address line on purpose. The rows of the index beside it read "Mitte, + * Germany", and a postcode or a county ("Троицкий муниципальный округ", which + * has no English name) next to them only made the list harder to read. + */ +function suggestionLine(p: PhotonProperties): string[] { + const name = p.name?.trim(); + const parts = [p.housenumber, p.street, p.district ?? p.locality, p.city, p.city ? undefined : p.state, p.country]; + const seen = new Set(name ? [name] : []); + const line: string[] = []; + for (const part of parts) { + const value = part?.trim(); + if (value && !seen.has(value)) { + seen.add(value); + line.push(value); + } + } + return line; +} + +/** + * Photon's hits with notable places before their namesakes. Photon ranks an + * artwork called "Neuschwanstein" above the castle and a street called + * "Colosseum" above the monument; a Wikidata link marks the place people + * mean, and streets, unlinked shops and unlinked artworks step back. Photon's + * own order stays the base and breaks ties. Measured on 74 queries without a + * context, the first hit went from 68 % to 76 % right, and from 61 % to 73 % + * asked from a map somewhere else. + */ +export function rankFeatures(features: PhotonFeature[]): PhotonFeature[] { + return features + .map((feature, index) => ({ feature, index, score: notability(feature.properties) - index })) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(({ feature }) => feature); +} + +function notability(p: PhotonProperties): number { + const linked = typeof p.extra?.wikidata === 'string'; + let score = linked ? 2.5 : 0; + if (p.osm_key === 'highway') score -= 2.5; + if (!linked && (p.osm_key === 'shop' || p.osm_key === 'office' || p.osm_key === 'craft')) score -= 1.5; + if (!linked && p.osm_key === 'tourism' && p.osm_value === 'artwork') score -= 2; + return score; +} + +/** + * The place a name stands for, which the result lists put first + * (`mergeRanked`): a country or a region, which is what its name means + * wherever the traveller is looking; a town, or a sight with a Wikidata link, + * which is what its name means from not too far away. The index beside Photon + * is a dataset of businesses and answers a town's name with shops that carry + * it, so without a lead "Tokyo" opened on a restaurant chain. + */ +export interface PhotonLead { + placeId: string; + name: string; + reach: 'everywhere' | 'nearby'; + /** What it is: Photon's value for the place (`city`, `town`, `municipality`), or `landmark` for a sight. */ + kind: string; + lat: number; + lng: number; + /** [west, north, east, south], or null where Photon gave none. */ + extent: [number, number, number, number] | null; +} + +const REGIONS = new Set(['country', 'state', 'province', 'region', 'territory']); +/** An island is what its name means too: Spitzbergen, Gran Canaria. */ +const ISLANDS = new Set(['island', 'archipelago']); + +/** The keys a sight is filed under; `landuse` is left out, it named a field in Italy "Eiffel". */ +const SIGHT_KEYS = new Set([ + 'tourism', + 'historic', + 'aeroway', + 'man_made', + 'building', + 'railway', + 'natural', + 'amenity', + 'leisure', + 'bridge', + 'waterway', +]); + +/** A three-letter code in capitals is an airport to a traveller, never the village or the country it spells. */ +const AIRPORT_CODE = /^[A-Z]{3}$/; + +export function asLead(feature: PhotonFeature): PhotonLead | null { + const p = feature.properties; + if (p.osm_key !== 'place' && p.osm_key !== 'boundary') return null; + const reach = + REGIONS.has(p.osm_value ?? '') || p.type === 'country' || p.type === 'state' + ? 'everywhere' + : p.type === 'city' || p.type === 'county' || ISLANDS.has(p.osm_value ?? '') + ? 'nearby' + : null; + const row = reach ? asSuggestion(feature) : null; + if (!row || !reach || row.lat === undefined || row.lng === undefined) return null; + return { + placeId: row.placeId, + name: row.mainText, + reach, + // A town filed as an administrative boundary (Mérida) is a town. + kind: (p.osm_key === 'boundary' ? p.type : p.osm_value) ?? p.type ?? 'place', + lat: row.lat, + lng: row.lng, + // The schema holds it to four numbers. + extent: (p.extent as PhotonLead['extent']) ?? null, + }; +} + +/** A sight as a lead: one with a Wikidata link, under a key sights are filed under. */ +function asSightLead(feature: PhotonFeature): PhotonLead | null { + const p = feature.properties; + if (typeof p.extra?.wikidata !== 'string' || !SIGHT_KEYS.has(p.osm_key ?? '')) return null; + const row = asSuggestion(feature); + if (!row || row.lat === undefined || row.lng === undefined) return null; + return { + placeId: row.placeId, + name: row.mainText, + reach: 'nearby', + kind: 'landmark', + lat: row.lat, + lng: row.lng, + extent: null, + }; +} + +/** + * The lead of a Photon answer (ranked by `rankFeatures`), or null. A country + * or a region in first place leads under any name, which keeps an exonym + * ("Mauretanien" answers Mauritania); anything else among the first three + * leads only when it is called what was typed, or a typo of it, when it may + * lead from where the traveller is looking (`leadsHere`), and when no + * namesake ranks above it. Without the name check "Paramraibo" opened on a + * district called Centrum and "Sagrada Familia" on a municipality in Brazil. + */ +export function pickLead( + features: PhotonFeature[], + query: string, + bias?: { lat: number; lng: number }, +): PhotonLead | null { + if (AIRPORT_CODE.test(query.trim())) return null; + const first = features[0] ? asLead(features[0]) : null; + if (first?.reach === 'everywhere') return first; + for (const feature of features.slice(0, 3)) { + const lead = asLead(feature) ?? asSightLead(feature); + const named = nameLike(query, lead?.name ?? feature.properties.name ?? ''); + if (lead && named && leadsHere(lead, bias)) return lead; + // A place of that name Photon ranks higher and that may not lead (the + // borough of Greenwich in London, a quarter, a municipality with nothing + // to say where the traveller is) outranks the ones behind it: leading with + // the next Greenwich put the town in Connecticut above London. A street or + // a shop of the name does not. + if (named && (feature.properties.osm_key === 'place' || feature.properties.osm_key === 'boundary')) return null; + } + return null; +} + +/** Whether a name is what was typed: the same, the start of it, a word of it, or a typo away. */ +export function nameLike(query: string, name: string): boolean { + const typed = foldName(query); + const called = foldName(name); + if (typed.length < 3 || !called) return false; + if (called.startsWith(typed) || typed.startsWith(called) || called.includes(` ${typed}`)) return true; + return editDistance(typed, called) <= (typed.length >= 8 ? 2 : 1); +} + +/** + * Edits between two strings, a swap of neighbours counting as one: the + * optimal string alignment distance. "Paramraibo" is two from "Paramaribo". + */ +export function editDistance(a: string, b: string): number { + if (Math.abs(a.length - b.length) > 2) return 3; + const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); + for (let j = 1; j <= b.length; j++) d[0][j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) + d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1); + } + } + return d[a.length][b.length]; +} + /** * The point a reverse lookup found, in the fields Nominatim's reverse answer * has. A town itself is its own `city`, and a named place is filed under its diff --git a/server/tests/unit/nest/maps.autocomplete.test.ts b/server/tests/unit/nest/maps.autocomplete.test.ts index 9626aeb7f..d1102478a 100644 --- a/server/tests/unit/nest/maps.autocomplete.test.ts +++ b/server/tests/unit/nest/maps.autocomplete.test.ts @@ -116,7 +116,7 @@ function settings(rows: Record): AppSettingsRepository { function withPhoton(rows: Suggestion[] | null, settingRows: Record = {}) { trekPlaces.on = true; const parts = buildMapsParts({} as PlacePhotoCacheService, settings(settingRows), noUsers, {} as never, {} as never); - const photon = vi.spyOn(parts.osm, 'autocompletePhoton').mockResolvedValue(rows); + const photon = vi.spyOn(parts.osm, 'suggestPhoton').mockResolvedValue(rows && { rows, lead: null }); return { svc: parts.svc, photon }; } @@ -540,7 +540,8 @@ describe('MapsService.autocompletePlaces', () => { mockSearch.mockResolvedValue([]); const { svc, photon } = withPhoton(null); photon.mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve([photonRow()]), PHOTON_GRACE_MS + 100)), + () => + new Promise((resolve) => setTimeout(() => resolve({ rows: [photonRow()], lead: null }), PHOTON_GRACE_MS + 100)), ); const result = await svc.autocompletePlaces(1, INPUT); diff --git a/server/tests/unit/nest/maps.merge-search.test.ts b/server/tests/unit/nest/maps.merge-search.test.ts index af05fda1f..35f49d549 100644 --- a/server/tests/unit/nest/maps.merge-search.test.ts +++ b/server/tests/unit/nest/maps.merge-search.test.ts @@ -1,5 +1,5 @@ /** - * MAPS-MERGE-001..008 — showing the index's answer and OpenStreetMap's together. + * MAPS-MERGE-001..014 — showing the index's answer and OpenStreetMap's together. * * Why this exists, in numbers. 128 places out of a real trip to Japan, all * saved through the old search, replayed against the index: 51.6 percent came @@ -9,6 +9,7 @@ * temple was one query away in OpenStreetMap. */ import { mergeSearchResults } from '../../../src/nest/maps/maps.helpers'; +import type { PhotonLead } from '../../../src/nest/maps/providers/photon.mapping'; import { describe, it, expect } from 'vitest'; @@ -120,4 +121,37 @@ describe('mergeSearchResults', () => { const osm = [p('長谷寺', 35.3125, 139.5335, 'openstreetmap')]; expect(mergeSearchResults(index, osm)).toHaveLength(2); }); + + it('MAPS-MERGE-013: opens on the lead, as the suggestions do, and drops the rows of its name inside it', () => { + const tokyo: PhotonLead = { + placeId: 'relation:1543125', + name: 'Tokyo', + reach: 'everywhere', + kind: 'province', + lat: 35.68, + lng: 139.76, + extent: [135.85, 35.9, 154.2, 20.21], + }; + const index = [ + { ...p('Tokyo Tokyo', 14.5, 121, 'trek-places'), address: 'Philippines' }, + { ...p('Tokyo', 35.69, 139.7, 'trek-places'), address: 'Shinjuku' }, + ]; + const osm = [{ ...p('Tokyo', 35.68, 139.76, 'openstreetmap'), osm_id: 'relation:1543125' }]; + + expect(mergeSearchResults(index, osm, 10, { lead: tokyo }).map((r) => r.osm_id)).toEqual([ + 'relation:1543125', + 'trek-places:Tokyo Tokyo', + ]); + }); + + it('MAPS-MERGE-014: one row for the same element under both ids, and one for rows that read the same', () => { + const index = [ + { ...p('Kröpeliner Tor', 54.0893, 12.1325, 'openstreetmap'), osm_id: 'way:1' }, + { ...p('Lidl', 54.1, 12.1, 'trek-places'), address: 'Rostock' }, + { ...p('Lidl', 54.2, 12.2, 'trek-places'), address: 'Rostock', osm_id: 'gers:2' }, + ]; + const osm = [{ ...p('Kröpeliner-Tor', 54.0894, 12.1326, 'openstreetmap'), osm_id: 'way:1' }]; + + expect(mergeSearchResults(index, osm).map((r) => r.osm_id)).toEqual(['way:1', 'trek-places:Lidl']); + }); }); diff --git a/server/tests/unit/nest/maps.search-merge.test.ts b/server/tests/unit/nest/maps.search-merge.test.ts index 23ddf32fd..9838d678c 100644 --- a/server/tests/unit/nest/maps.search-merge.test.ts +++ b/server/tests/unit/nest/maps.search-merge.test.ts @@ -319,7 +319,13 @@ describe('MapsService.searchPlaces — the merged path', () => { await make().searchPlaces(1, "L'Osteria", 'de', { lat: 54.0879, lng: 12.1408, radius: 5000 }); - expect(mockSearch).toHaveBeenCalledWith("L'Osteria", { lat: 54.0879, lng: 12.1408, limit: 10, lang: 'de' }); + expect(mockSearch).toHaveBeenCalledWith("L'Osteria", { + lat: 54.0879, + lng: 12.1408, + limit: 10, + sources: 'index,osm', + lang: 'de', + }); // Without the viewbox, "Hase-dera" answers with the temple in Nara rather // than the one the user is standing beside in Kamakura. const params = mockNominatim.mock.calls[0][1] as URLSearchParams; diff --git a/server/tests/unit/nest/maps.suggestion-merge.test.ts b/server/tests/unit/nest/maps.suggestion-merge.test.ts index 95205cecb..8938e4711 100644 --- a/server/tests/unit/nest/maps.suggestion-merge.test.ts +++ b/server/tests/unit/nest/maps.suggestion-merge.test.ts @@ -1,5 +1,5 @@ /** - * MAPS-SUGMERGE-001..004: the index's suggestions and Photon's in one list (#2877). + * MAPS-SUGMERGE-001..010: the index's suggestions and Photon's in one list (#2877). * * The keystroke path asks the index with its OpenStreetMap layer and Photon at * once. Both carry OpenStreetMap places, so the same element can come back from @@ -7,6 +7,8 @@ * Photon found. */ import { mergeSuggestions } from '../../../src/nest/maps/maps-index.helpers'; +import { LEAD_TOWN_REACH_M } from '../../../src/nest/maps/maps.helpers'; +import type { PhotonLead } from '../../../src/nest/maps/providers/photon.mapping'; import { describe, it, expect } from 'vitest'; @@ -58,4 +60,87 @@ describe('mergeSuggestions', () => { expect(mergeSuggestions([], photon)).toHaveLength(8); expect(mergeSuggestions([], photon, 3).map((s) => s.placeId)).toEqual(['node:0', 'node:1', 'node:2']); }); + + // Measured on "Berlin": the index's first row is a business filed under the + // city's name, Photon's first row is the city. + const berlin: PhotonLead = { + placeId: 'relation:62422', + name: 'Berlin', + reach: 'nearby', + kind: 'city', + lat: 52.5173885, + lng: 13.3951309, + extent: [13.088345, 52.6755087, 13.7611609, 52.3382448], + }; + const berlinIndex = [ + row('gers:office', 'Berlin', 52.512, 13.32, 'trek-places'), + row('node:484323', 'Berlin', 52.415, 13.197, 'openstreetmap'), + row('node:21302157', 'Berlin-Tiergarten', 52.514, 13.336, 'openstreetmap'), + ]; + const berlinPhoton = [ + row('relation:62422', 'Berlin', 52.5173885, 13.3951309, 'openstreetmap'), + row('relation:170184', 'Berlin', 44.4697, -71.1811, 'openstreetmap'), + ]; + + it('MAPS-SUGMERGE-005: opens on the lead and drops the rows of its name inside it', () => { + const list = mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: berlin }); + + // The "Berlin" in Kleinmachnow lies outside the city, so it stays. + expect(list.map((s) => s.placeId)).toEqual(['relation:62422', 'node:484323', 'node:21302157', 'relation:170184']); + }); + + it('MAPS-SUGMERGE-006: a town leads only near where the traveller looks', () => { + const tokyo = { lat: 35.68, lng: 139.76 }; + expect(mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: berlin, bias: tokyo })[0].placeId).toBe( + 'gers:office', + ); + const potsdam = { lat: 52.39, lng: 13.06 }; + expect(mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: berlin, bias: potsdam })[0].placeId).toBe( + 'relation:62422', + ); + expect(LEAD_TOWN_REACH_M).toBe(1_000_000); + }); + + it('MAPS-SUGMERGE-007: a country leads from anywhere', () => { + const mauritania: PhotonLead = { + placeId: 'relation:192763', + name: 'Mauritania', + reach: 'everywhere', + kind: 'country', + lat: 20.25, + lng: -10.33, + extent: [-17.2380959, 27.314942, -4.8333344, 14.7209909], + }; + const index = [row('gers:embassy', 'Islamische Republik Mauretanien', 52.51, 13.4, 'trek-places')]; + const photon = [row('relation:192763', 'Mauritania', 20.25, -10.33, 'openstreetmap')]; + + const list = mergeSuggestions(index, photon, 8, { lead: mauritania, bias: { lat: 52.5, lng: 13.4 } }); + expect(list.map((s) => s.placeId)).toEqual(['relation:192763', 'gers:embassy']); + }); + + it("MAPS-SUGMERGE-008: leads with the layer's copy where Photon's was dropped as the same element", () => { + const index = [row('gers:shop', 'Berlin Döner', 52.52, 13.4, 'trek-places'), berlinPhoton[0]]; + + const list = mergeSuggestions(index, berlinPhoton, 8, { lead: berlin }); + expect(list.map((s) => s.placeId)).toEqual(['relation:62422', 'gers:shop', 'relation:170184']); + }); + + it('MAPS-SUGMERGE-009: shows rows that read the same once, and fills the list up behind them', () => { + const branch = (id: string, lat: number) => ({ + ...row(id, 'Tokyo Tokyo', lat, 121, 'trek-places'), + secondaryText: 'Philippines', + }); + const index = [branch('gers:1', 14.5), branch('gers:2', 14.6), branch('gers:3', 14.7)]; + const photon = [row('relation:1543125', 'Tokyo', 35.68, 139.76, 'openstreetmap')]; + + expect(mergeSuggestions(index, photon, 2).map((s) => s.placeId)).toEqual(['gers:1', 'relation:1543125']); + }); + + it('MAPS-SUGMERGE-010: with nothing to say where the traveller is, a municipality does not lead', () => { + const municipality: PhotonLead = { ...berlin, kind: 'municipality' }; + expect(mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: municipality })[0].placeId).toBe('gers:office'); + expect( + mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: municipality, bias: { lat: 52.4, lng: 13.1 } })[0].placeId, + ).toBe('relation:62422'); + }); }); diff --git a/server/tests/unit/nest/osm.photon.test.ts b/server/tests/unit/nest/osm.photon.test.ts index d033017b8..4db49d7bc 100644 --- a/server/tests/unit/nest/osm.photon.test.ts +++ b/server/tests/unit/nest/osm.photon.test.ts @@ -1,9 +1,9 @@ /** - * OSM-PHOTON-001..014: the OpenStreetMap search and reverse lookup ask Photon - * first where the installation holds a key, answer in the shape Nominatim's - * answers always had, and fall back to Nominatim whenever Photon gives none. - * Lookups by id stay on Nominatim. The network is a stubbed global fetch - * routed by host. + * OSM-PHOTON-001..020: the OpenStreetMap search and reverse lookup are Photon's + * where the installation holds a key, answer in the shape Nominatim's answers + * always had, and leave Nominatim to installations without the TREK services. + * A lookup by id is Overpass and Photon, Nominatim only where Overpass cannot + * place the element. The network is a stubbed global fetch routed by host. */ import { deriveServices } from '../../../src/app-config/boot-derive'; import { OsmClient } from '../../../src/nest/maps/providers/osm.client'; @@ -33,6 +33,7 @@ const NOMINATIM_REVERSE = { let identity: FakeServiceIdentity; let photonReply: () => Response; +let overpassReply: () => Response; let osm: OsmClient; const hosts: string[] = []; @@ -40,6 +41,20 @@ beforeEach(() => { identity = new FakeServiceIdentity(); serviceIdentitySlot.install(identity); photonReply = () => photonAnswer([gateFeature()]); + overpassReply = () => + new Response( + JSON.stringify({ + elements: [ + { + type: 'way', + id: 518071791, + center: { lat: 52.5163, lon: 13.3777 }, + tags: { name: 'Brandenburger Tor', 'name:en': 'Brandenburg Gate', website: 'https://example.org' }, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); hosts.length = 0; vi.stubEnv('NOMINATIM_URL', ''); vi.stubGlobal( @@ -48,6 +63,7 @@ beforeEach(() => { const url = new URL(String(input)); hosts.push(url.hostname); if (url.hostname === 'photon.liketrek.com') return photonReply(); + if (url.hostname === 'overpass-api.de') return overpassReply(); const body = url.pathname.endsWith('/reverse') ? NOMINATIM_REVERSE : [NOMINATIM_GATE]; return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); }), @@ -82,10 +98,15 @@ describe('search', () => { }); }); - it('OSM-PHOTON-002: asks Nominatim when Photon fails, with the bias it would have had', async () => { + it('OSM-PHOTON-002: a Photon that fails is an empty answer where Photon is the geocoder; Nominatim only without the services', async () => { photonReply = () => photonAnswer([], 503); + expect(await osm.searchNominatim('Brandenburger Tor', 'de', 'interactive', { lat: 52.5, lng: 13.4 })).toEqual([]); + expect(hosts).toEqual(['photon.liketrek.com']); + + hosts.length = 0; + identity.bearer = null; const places = await osm.searchNominatim('Brandenburger Tor', 'de', 'interactive', { lat: 52.5, lng: 13.4 }); - expect(hosts).toEqual(['photon.liketrek.com', 'nominatim.openstreetmap.org']); + expect(hosts).toEqual(['nominatim.openstreetmap.org']); expect(places[0]).toMatchObject({ osm_id: 'way:518071791' }); }); @@ -95,26 +116,20 @@ describe('search', () => { expect(places.map((p) => p.osm_id)).toEqual(['node:7']); }); - it('OSM-PHOTON-010: asks Nominatim when Photon finds nothing usable, without holding it against Photon', async () => { + it('OSM-PHOTON-010: an empty answer from Photon is the answer; Nominatim is not asked beside it', async () => { photonReply = () => photonAnswer([]); - expect((await osm.searchNominatim('Brandenburger Tor', 'de'))[0]).toMatchObject({ osm_id: 'way:518071791' }); + expect(await osm.searchNominatim('Brandenburger Tor', 'de')).toEqual([]); photonReply = () => photonAnswer([gateFeature({ osm_type: undefined })]); - expect((await osm.searchNominatim('Brandenburger Tor', 'de'))[0]).toMatchObject({ wikidata: 'Q82425' }); - photonReply = () => photonAnswer([gateFeature()]); - await osm.searchNominatim('Brandenburger Tor', 'de'); - expect(hosts).toEqual([ - 'photon.liketrek.com', - 'nominatim.openstreetmap.org', - 'photon.liketrek.com', - 'nominatim.openstreetmap.org', - 'photon.liketrek.com', - ]); + expect(await osm.searchNominatim('Brandenburger Tor', 'de')).toEqual([]); + expect(hosts).toEqual(['photon.liketrek.com', 'photon.liketrek.com']); }); - it('OSM-PHOTON-011: a bulk import on the background lane asks Nominatim alone', async () => { - await osm.searchNominatim('Brandenburger Tor', 'de', 'background'); + it('OSM-PHOTON-011: a bulk import on the background lane asks Photon too, for its search and its reverse lookup', async () => { + expect((await osm.searchNominatim('Brandenburger Tor', 'de', 'background'))[0]).toMatchObject({ + osm_id: 'way:518071791', + }); await osm.reverse('52.5', '13.4', 'de', { lane: 'background' }); - expect(hosts).toEqual(['nominatim.openstreetmap.org', 'nominatim.openstreetmap.org']); + expect(hosts).toEqual(['photon.liketrek.com', 'photon.liketrek.com']); }); it('OSM-PHOTON-012: carries the wiki tags of a Photon hit into the record', async () => { @@ -145,7 +160,7 @@ describe('search', () => { { placeId: 'way:518071791', mainText: 'Brandenburger Tor', - secondaryText: 'Pariser Platz, Mitte, Berlin, 10117, Deutschland', + secondaryText: 'Pariser Platz, Mitte, Berlin, Deutschland', source: 'openstreetmap', lat: 52.5163, lng: 13.3777, @@ -153,6 +168,67 @@ describe('search', () => { ]); }); + it("OSM-PHOTON-015: hands the suggestion list Photon's first hit as its lead where it names a town", async () => { + const town = gateFeature({ + osm_type: 'R', + osm_id: 62422, + osm_key: 'place', + osm_value: 'city', + type: 'city', + name: 'Berlin', + extent: [13.088345, 52.6755087, 13.7611609, 52.3382448], + }); + photonReply = () => photonAnswer([town, gateFeature()]); + const answer = await osm.suggestPhoton('Berl', 'de'); + expect(answer?.rows.map((r) => r.placeId)).toEqual(['relation:62422', 'way:518071791']); + expect(answer?.lead).toMatchObject({ placeId: 'relation:62422', reach: 'nearby' }); + + // A sight with a Wikidata link leads too, when it is called what was typed. + photonReply = () => photonAnswer([gateFeature(), town]); + expect((await osm.suggestPhoton('Bran', 'de'))?.lead).toMatchObject({ placeId: 'way:518071791', kind: 'landmark' }); + expect((await osm.suggestPhoton('Pariser', 'de'))?.lead).toBeNull(); + }); + + it('OSM-PHOTON-016: the search hands its records ranked and its lead, and null where Photon does not answer', async () => { + const street = gateFeature({ osm_id: 1, osm_key: 'highway', osm_value: 'pedestrian', extra: undefined }); + photonReply = () => photonAnswer([street, gateFeature()]); + const answer = await osm.searchPhoton('Brandenburger Tor', 'de'); + // The street of the same name steps back behind the sight with a Wikidata link. + expect(answer?.records.map((r) => r.osm_id)).toEqual(['way:518071791', 'way:1']); + expect(answer?.lead).toMatchObject({ placeId: 'way:518071791' }); + + identity.bearer = null; + expect(await osm.searchPhoton('Brandenburger Tor', 'de')).toBeNull(); + }); + + it('OSM-PHOTON-020: an airport code in capitals is asked as the airport at its place, and leads with the aerodrome', async () => { + const asked: URLSearchParams[] = []; + const aerodrome = gateFeature({ + osm_type: 'W', + osm_id: 12345, + osm_key: 'aeroway', + osm_value: 'aerodrome', + name: 'John F. Kennedy International Airport', + }); + (aerodrome.geometry as { coordinates: number[] }).coordinates = [-73.7781, 40.6413]; + photonReply = () => photonAnswer([aerodrome]); + const fetchStub = globalThis.fetch as unknown as ReturnType; + const inner = fetchStub.getMockImplementation()!; + fetchStub.mockImplementation(async (input: string | URL) => { + asked.push(new URL(String(input)).searchParams); + return inner(input); + }); + + const answer = await osm.suggestPhoton('JFK', 'en'); + expect(asked[0].get('q')).toBe('John F. Kennedy International Airport'); + expect(asked[0].get('lat')).toBe('40.639447'); + expect(answer?.lead).toMatchObject({ placeId: 'way:12345', reach: 'everywhere', kind: 'airport' }); + + // Lower case is a word being typed, not a code. + await osm.suggestPhoton('jfk', 'en'); + expect(asked[1].get('q')).toBe('jfk'); + }); + it('OSM-PHOTON-014: answers null without Photon rather than asking Nominatim, whose policy rules out autocomplete', async () => { identity.bearer = null; expect(await osm.autocompletePhoton('Brandenb', 'de')).toBeNull(); @@ -176,30 +252,73 @@ describe('reverse', () => { expect((await osm.reverse('52.5163', '13.3777', 'de', { locality: true })).name).toBe('Berlin'); }); - it('OSM-PHOTON-007: asks Nominatim when Photon fails or finds nothing there', async () => { - photonReply = () => photonAnswer([], 429); - expect(await osm.reverse('52.5', '13.4', 'de')).toEqual({ - name: 'Unter den Linden', - address: 'Unter den Linden 77, Mitte, Berlin, Deutschland', - }); + it('OSM-PHOTON-007: asks again within 50 km when nothing is next to the point, and never Nominatim beside Photon', async () => { + const asked: string[] = []; photonReply = () => photonAnswer([]); - expect((await osm.reverse('52.5', '13.4', 'de')).name).toBe('Unter den Linden'); - expect(hosts).toEqual([ - 'photon.liketrek.com', - 'nominatim.openstreetmap.org', - 'photon.liketrek.com', - 'nominatim.openstreetmap.org', - ]); + const fetchStub = globalThis.fetch as unknown as ReturnType; + const inner = fetchStub.getMockImplementation()!; + fetchStub.mockImplementation(async (input: string | URL) => { + asked.push(new URL(String(input)).searchParams.get('radius') ?? ''); + return inner(input); + }); + expect(await osm.reverse('46.0', '104.0', 'de')).toEqual({ name: null, address: null }); + expect(asked).toEqual(['', '50']); + photonReply = () => photonAnswer([], 429); + expect(await osm.reverse('52.5', '13.4', 'de')).toEqual({ name: null, address: null }); + expect(hosts).not.toContain('nominatim.openstreetmap.org'); }); - it('OSM-PHOTON-008: the raw answer for a pasted link comes from Photon, else from Nominatim', async () => { + it('OSM-PHOTON-008: the raw answer for a pasted link comes from Photon; a miss is an empty answer, not Nominatim', async () => { expect((await osm.reverseRaw(52.5163, 13.3777)).address?.tourism).toBe('Brandenburger Tor'); photonReply = () => photonAnswer([], 500); - expect(await osm.reverseRaw(52.5, 13.4)).toEqual(NOMINATIM_REVERSE); + expect(await osm.reverseRaw(52.5, 13.4)).toEqual({}); + expect(hosts).not.toContain('nominatim.openstreetmap.org'); + }); + + it("OSM-PHOTON-017: without the TREK services the reverse lookup is Nominatim's, as it always was", async () => { + identity.bearer = null; + expect((await osm.reverse('52.5', '13.4', 'de')).name).toBe('Unter den Linden'); + expect(hosts).toEqual(['nominatim.openstreetmap.org']); }); }); -it('OSM-PHOTON-009: a lookup by id stays on Nominatim', async () => { - await osm.lookupNominatim('way', '518071791', 'de'); - expect(hosts).toEqual(['nominatim.openstreetmap.org']); +it('OSM-PHOTON-009: a lookup by id is Overpass for the tags and the place, Photon for the name and address', async () => { + const details = await osm.elementDetails('way:518071791', 'en'); + expect(details).toMatchObject({ + name: 'Brandenburg Gate', + address: 'Brandenburger Tor, Pariser Platz, Mitte, Berlin, 10117, Deutschland', + lat: 52.5163, + lng: 13.3777, + osm_id: 'way:518071791', + website: 'https://example.org', + }); + expect(hosts).toEqual(['overpass-api.de', 'photon.liketrek.com']); +}); + +it('OSM-PHOTON-018: Nominatim names the element only when Overpass cannot place it, or without the TREK services', async () => { + overpassReply = () => new Response('busy', { status: 504 }); + await osm.elementDetails('way:518071791', 'de'); + expect(hosts.filter((h) => h === 'nominatim.openstreetmap.org')).toHaveLength(1); + hosts.length = 0; + identity.bearer = null; + await osm.elementDetails('way:518071791', 'de'); + expect(hosts).toContain('nominatim.openstreetmap.org'); + expect(hosts).not.toContain('photon.liketrek.com'); +}); + +it('OSM-PHOTON-019: the identity of a place comes from Photon inside the box, the sight before the station of its name', async () => { + const station = gateFeature({ + osm_type: 'N', + osm_id: 3862767512, + osm_key: 'railway', + osm_value: 'station', + extra: { wikidata: 'Q477185' }, + }); + photonReply = () => photonAnswer([station, gateFeature()]); + const identityAnswer = await osm.resolveOsmIdentity('Brandenburger Tor', 52.5163, 13.3777, { lang: 'de' }); + expect(identityAnswer).toMatchObject({ + osmUrl: 'https://www.openstreetmap.org/way/518071791', + tags: { wikidata: 'Q82425' }, + }); + expect(hosts).toEqual(['photon.liketrek.com']); }); diff --git a/server/tests/unit/nest/photon.mapping.test.ts b/server/tests/unit/nest/photon.mapping.test.ts index 4b9d3a468..0ff22011e 100644 --- a/server/tests/unit/nest/photon.mapping.test.ts +++ b/server/tests/unit/nest/photon.mapping.test.ts @@ -1,8 +1,14 @@ /** - * PHOTON-MAP-001..016: Photon's answers read into the shape Nominatim gives, - * the rows of the suggestion list, and the language Photon is asked in. + * PHOTON-MAP-001..029: Photon's answers read into the shape Nominatim gives, + * the rows of the suggestion list, the lead the list opens on, and the + * language Photon is asked in. */ import { + asLead, + editDistance, + nameLike, + pickLead, + rankFeatures, asNominatimHit, asNominatimReverse, asSuggestion, @@ -128,7 +134,7 @@ describe('asSuggestion', () => { expect(asSuggestion(feature())).toEqual({ placeId: 'way:518071791', mainText: 'Brandenburger Tor', - secondaryText: 'Pariser Platz, Mitte, Berlin, 10117, Deutschland', + secondaryText: 'Pariser Platz, Mitte, Berlin, Deutschland', source: 'openstreetmap', lat: 52.5163, lng: 13.3777, @@ -138,7 +144,7 @@ describe('asSuggestion', () => { it('PHOTON-MAP-015: keeps a name that holds a comma whole, and titles a house without one by its number', () => { const bar = asSuggestion(feature({ name: 'Bar, Café Einstein' })); expect(bar?.mainText).toBe('Bar, Café Einstein'); - expect(bar?.secondaryText).toBe('Pariser Platz, Mitte, Berlin, 10117, Deutschland'); + expect(bar?.secondaryText).toBe('Pariser Platz, Mitte, Berlin, Deutschland'); const house = asSuggestion( feature({ name: undefined, housenumber: '77', street: 'Unter den Linden', postcode: undefined }), @@ -155,3 +161,194 @@ describe('asSuggestion', () => { expect(asSuggestion(bare.features[0])).toBeNull(); }); }); + +describe('asSuggestion, the line below the name', () => { + it('PHOTON-MAP-017: leaves out postcode and county, and names the region only where there is no town', () => { + const village = asSuggestion( + feature({ + osm_key: 'place', + osm_value: 'village', + name: 'Berlin', + street: undefined, + postcode: '457130', + district: undefined, + city: undefined, + county: 'Троицкий муниципальный округ', + state: 'Chelyabinsk Oblast', + country: 'Russia', + }), + ); + expect(village?.secondaryText).toBe('Chelyabinsk Oblast, Russia'); + expect(asSuggestion(feature({ county: 'Berlin', postcode: '10117' }))?.secondaryText).toBe( + 'Pariser Platz, Mitte, Berlin, Deutschland', + ); + }); +}); + +describe('asLead', () => { + const city = (properties: Record = {}) => + feature({ + osm_type: 'R', + osm_id: 62422, + osm_key: 'place', + osm_value: 'city', + type: 'city', + name: 'Berlin', + street: undefined, + postcode: undefined, + district: undefined, + city: undefined, + extent: [13.088345, 52.6755087, 13.7611609, 52.3382448], + ...properties, + }); + + it('PHOTON-MAP-018: reads a town as a lead from nearby, with its extent', () => { + expect(asLead(city())).toEqual({ + placeId: 'relation:62422', + name: 'Berlin', + reach: 'nearby', + kind: 'city', + lat: 52.5163, + lng: 13.3777, + extent: [13.088345, 52.6755087, 13.7611609, 52.3382448], + }); + }); + + it('PHOTON-MAP-019: reads a country or a region as a lead from everywhere, extent or none', () => { + expect(asLead(city({ osm_value: 'country', type: 'country', name: 'Mauritania' }))?.reach).toBe('everywhere'); + // Tokyo is a province Photon files on its town layer. + expect(asLead(city({ osm_value: 'province', name: 'Tokyo' }))?.reach).toBe('everywhere'); + expect(asLead(city({ extent: undefined }))?.extent).toBeNull(); + }); + + it('PHOTON-MAP-020: has no lead for a sight, a street or a quarter', () => { + expect(asLead(feature())).toBeNull(); + expect(asLead(city({ osm_key: 'highway', osm_value: 'primary', type: 'street' }))).toBeNull(); + expect(asLead(city({ osm_value: 'suburb', type: 'district' }))).toBeNull(); + }); +}); + +describe('rankFeatures', () => { + it('PHOTON-MAP-021: puts a place with a Wikidata link before its unlinked namesakes, Photon order otherwise', () => { + const art = feature({ + osm_id: 1, + osm_key: 'tourism', + osm_value: 'artwork', + extra: undefined, + name: 'Neuschwanstein', + }); + const castle = feature({ osm_id: 2, osm_key: 'tourism', osm_value: 'attraction', name: 'Schloss Neuschwanstein' }); + const street = feature({ + osm_id: 3, + osm_key: 'highway', + osm_value: 'residential', + extra: undefined, + name: 'Neuschwansteinstraße', + }); + const shop = feature({ + osm_id: 4, + osm_key: 'shop', + osm_value: 'gift', + extra: undefined, + name: 'Neuschwanstein Souvenirs', + }); + expect(rankFeatures([art, street, castle, shop]).map((f) => f.properties.osm_id)).toEqual([2, 1, 4, 3]); + expect(rankFeatures([castle, art]).map((f) => f.properties.osm_id)).toEqual([2, 1]); + }); +}); + +describe('pickLead', () => { + const town = (properties: Record) => + feature({ + osm_key: 'place', + osm_value: 'city', + type: 'city', + street: undefined, + district: undefined, + city: undefined, + ...properties, + }); + + it('PHOTON-MAP-022: a country or region first leads under any name, so an exonym keeps it', () => { + const lead = pickLead( + [town({ osm_type: 'R', osm_id: 192763, osm_value: 'country', type: 'country', name: 'Mauritania' })], + 'Mauretanien', + ); + expect(lead).toMatchObject({ placeId: 'relation:192763', reach: 'everywhere' }); + }); + + it('PHOTON-MAP-023: anything else leads only when called what was typed, or a typo of it', () => { + const centrum = town({ + osm_type: 'R', + osm_id: 1, + osm_key: 'boundary', + osm_value: 'administrative', + type: 'county', + name: 'Centrum', + }); + const paramaribo = town({ osm_type: 'N', osm_id: 2, name: 'Paramaribo' }); + expect(pickLead([centrum, paramaribo], 'Paramraibo')).toMatchObject({ placeId: 'node:2' }); + expect(pickLead([centrum], 'Paramraibo')).toBeNull(); + }); + + it('PHOTON-MAP-024: no lead for an airport code, nor for a municipality with nothing to say where the traveller is', () => { + expect(pickLead([town({ name: 'FRA' })], 'FRA')).toBeNull(); + const municipality = town({ osm_value: 'municipality', name: 'Sagrada Família' }); + expect(pickLead([municipality], 'Sagrada Familia')).toBeNull(); + expect(pickLead([municipality], 'Sagrada Familia', { lat: -20, lng: -44 })).not.toBeNull(); + }); +}); + +describe('pickLead, namesakes', () => { + const place = (properties: Record) => + feature({ osm_key: 'place', street: undefined, district: undefined, city: undefined, ...properties }); + + it('PHOTON-MAP-027: a namesake ranked higher that may not lead leaves the list without a lead', () => { + const london = place({ osm_type: 'R', osm_id: 1, osm_value: 'town', type: 'district', name: 'Greenwich' }); + const connecticut = place({ osm_type: 'R', osm_id: 2, osm_value: 'town', type: 'city', name: 'Greenwich' }); + expect(pickLead([london, connecticut], 'Greenwich')).toBeNull(); + expect(pickLead([connecticut, london], 'Greenwich')).toMatchObject({ placeId: 'relation:2' }); + }); + + it('PHOTON-MAP-029: a street of the name ranked higher does not block, and an island leads', () => { + const street = place({ + osm_type: 'W', + osm_id: 4, + osm_key: 'highway', + osm_value: 'primary', + type: 'street', + name: 'Spitzbergen', + }); + const island = place({ osm_type: 'R', osm_id: 5, osm_value: 'island', type: 'other', name: 'Spitzbergen' }); + expect(pickLead([street, island], 'Spitzbergen')).toMatchObject({ placeId: 'relation:5', kind: 'island' }); + }); + + it('PHOTON-MAP-028: a town filed as an administrative boundary leads as a town', () => { + const merida = place({ + osm_type: 'R', + osm_id: 3, + osm_key: 'boundary', + osm_value: 'administrative', + type: 'city', + name: 'Mérida', + }); + expect(pickLead([merida], 'Mérida')).toMatchObject({ placeId: 'relation:3', kind: 'city' }); + }); +}); + +describe('nameLike and editDistance', () => { + it('PHOTON-MAP-025: counts a swap of neighbours as one edit', () => { + expect(editDistance('paramraibo', 'paramaribo')).toBe(2); + expect(editDistance('tokio', 'tokyo')).toBe(1); + expect(editDistance('ab', 'ba')).toBe(1); + expect(editDistance('berlin', 'berlin')).toBe(0); + }); + + it('PHOTON-MAP-026: takes the same name, its start, a word of it or a near typo, and nothing shorter than three letters', () => { + expect(nameLike('Berl', 'Berlin')).toBe(true); + expect(nameLike('Tor', 'Brandenburger Tor')).toBe(true); + expect(nameLike('Tokio', 'Tokyo')).toBe(true); + expect(nameLike('Mailand', 'Milan')).toBe(false); + expect(nameLike('Be', 'Berlin')).toBe(false); + }); +});