mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
maps: lead with the place a name stands for, and leave Nominatim to installs without the services
The suggestions and the full search now share one merge. Photon's hits are ranked with places that have a Wikidata link before their unlinked namesakes, and its context bias is narrower (zoom 13). The list opens on a lead where Photon's answer has one: a country or region always; a town, island or sight when it is called what was typed (or a near typo of it), lies within 1000 km of where the traveller is looking and no place of that name ranks above it. Rows of the lead's name inside it, the same element under two ids and rows that read the same are shown once. A three-letter code in capitals is asked for as the airport of the bundled list. Where Photon is the geocoder it answers alone: an empty answer and a miss are the answer for search and imports, the reverse lookup asks again within 50 km, a Google place is matched inside a box through Photon, and an opened OpenStreetMap place is placed by Overpass and named by Photon. Nominatim stays the search of installations without the TREK services or with an operator's own Nominatim. Measured on 5000 places from Wikidata: the right place first while typing 48 % before, 65 % now; full search 53 % to 68 %; no repeated rows.
This commit is contained in:
@@ -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<string, unknown> {
|
||||
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. */
|
||||
|
||||
@@ -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<string, unknown>[],
|
||||
fromOsm: Record<string, unknown>[],
|
||||
limit = 10,
|
||||
opts: { lead?: PhotonLead | null; bias?: { lat: number; lng: number } } = {},
|
||||
): Record<string, unknown>[] {
|
||||
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<string, unknown>): 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<T>(
|
||||
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<string>();
|
||||
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. */
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
}).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<string, unknown>[], 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' };
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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<ReturnType<typeof asSuggestion>> & { 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<string, string>; 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<NominatimReverse | null> {
|
||||
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<NominatimReverse> {
|
||||
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<Record<string, unknown> | 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<OverpassElement | null> {
|
||||
/** `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<OverpassElement | null> {
|
||||
const typeMap: Record<string, string> = { 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',
|
||||
|
||||
@@ -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<PhotonFeature[] | null> {
|
||||
reverse(
|
||||
lat: number,
|
||||
lng: number,
|
||||
lang?: string,
|
||||
timeoutMs?: number,
|
||||
opts: { limit?: number; radius?: number } = {},
|
||||
): Promise<PhotonFeature[] | null> {
|
||||
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));
|
||||
|
||||
@@ -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<string>(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<number>(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
|
||||
|
||||
@@ -116,7 +116,7 @@ function settings(rows: Record<string, string>): AppSettingsRepository {
|
||||
function withPhoton(rows: Suggestion[] | null, settingRows: Record<string, string> = {}) {
|
||||
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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {}) =>
|
||||
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<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user