feat(maps): ask Photon beside the index for search-as-you-type suggestions

The suggestion list asked only the TREK index and its OpenStreetMap layer,
because Nominatim's usage policy rules out autocomplete. The layer is a slice
of OpenStreetMap, so many places showed up only once the full search was sent
(#2877). Photon is built for this keystroke: the suggestions now ask it in
parallel with the index and interleave its rows, dropping any the list
already holds, by OSM id or by the search list's same-place rule.

Without Photon (services off, an operator's own Nominatim, a paused or failed
answer) the list reads exactly as before. A failing index no longer throws
away what Photon found, and the Google-only switch skips Photon as it skips
the index.

Refs #2877
This commit is contained in:
Maurice
2026-10-29 13:49:06 +01:00
parent 81b3657528
commit 7d669cb5c3
9 changed files with 310 additions and 33 deletions
+17 -1
View File
@@ -5,7 +5,7 @@
* the decision to make them is MapsService's.
*/
import { getCountryFromCoords } from '../../utils/country-borders';
import { buildOsmDetails, parsePoiCategories, samePlace, toApiLang } from './maps.helpers';
import { buildOsmDetails, interleave, parsePoiCategories, samePlace, toApiLang } from './maps.helpers';
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
import {
INDEX_MAX_CATEGORY_CHARS,
@@ -335,6 +335,22 @@ export function indexSuggestions(found: TrekSearchHit[], lang?: string): MapsAut
);
}
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.
*/
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);
}
/**
* The details record of a place picked out of the index, with what
* OpenStreetMap knows about the same building filled in.
+8 -4
View File
@@ -115,11 +115,15 @@ export function mergeSearchResults(
limit = 10,
): Record<string, unknown>[] {
const extra = fromOsm.filter((o) => !fromIndex.some((i) => samePlace(i, o)));
return interleave(fromIndex, extra, limit);
}
const out: Record<string, unknown>[] = [];
for (let i = 0; i < Math.max(fromIndex.length, extra.length) && out.length < limit; i++) {
if (i < fromIndex.length) out.push(fromIndex[i]);
if (i < extra.length && out.length < limit) out.push(extra[i]);
/** One row of each list in turn, the first list leading, up to `limit` rows. */
export function interleave<T>(first: T[], second: T[], limit: number): T[] {
const out: T[] = [];
for (let i = 0; i < Math.max(first.length, second.length) && out.length < limit; i++) {
if (i < first.length) out.push(first[i]);
if (i < second.length && out.length < limit) out.push(second[i]);
}
return out;
}
+31 -20
View File
@@ -11,6 +11,7 @@ import {
indexRequests,
indexSuggestions,
mergeIndexPages,
mergeSuggestions,
} from './maps-index.helpers';
import { NEARBY_DEFAULT_LIMIT, NEARBY_DEFAULT_RADIUS_M, nearbyCacheKey, nearestFirst } from './maps-nearby.helpers';
import { MapsUrlResolver, type ResolvedMapsUrl } from './maps-url.resolver';
@@ -542,29 +543,39 @@ export class MapsService {
}
if (this.trekPlacesEnabled() && !(await this.selector.googleOnly(keyed))) {
try {
const centre = boxCentre;
// Both layers here, unlike the explicit search above. That path asks
// Nominatim in parallel and would get the same OpenStreetMap places
// twice; this one asks nobody else, because Nominatim's usage policy
// names autocomplete as unacceptable use. So the layer is not a second
// opinion here, it is the only place the missing names live.
//
// Measured on the case that surfaced it: "Tokio station" typed from
// Tokyo returned a weigh station in Ritzville and a station in Mexico
// from the index alone, and "Tokio Hauptbahnhof" in second place with
// the layer. The index holds businesses; stations, temples and bridges
// are in OpenStreetMap, and so is every exonym a traveller types.
const found = await trekPlacesSearch(input, {
lat: centre?.lat,
lng: centre?.lng,
// Both layers here, unlike the explicit search above. That path asks
// Nominatim in parallel and would get the same OpenStreetMap places
// twice; this one never asks Nominatim, because its usage policy names
// autocomplete as unacceptable use.
//
// Measured on the case that surfaced the layer: "Tokio station" typed
// from Tokyo returned a weigh station in Ritzville and a station in
// Mexico from the index alone, and "Tokio Hauptbahnhof" in second place
// with the layer. The index holds businesses; stations, temples and
// bridges are in OpenStreetMap, and so is every exonym a traveller types.
//
// Photon beside them (#2877). The layer is a slice of OpenStreetMap, and
// a place missing from it showed up only once the search was sent.
// Photon is built for this keystroke and answers null when the services
// are off, so the pair costs the slower one and an install without
// Photon reads exactly as before. Each side swallows its own failure, so
// one that is down does not throw away what the other found.
const [found, photon] = await Promise.all([
trekPlacesSearch(input, {
lat: boxCentre?.lat,
lng: boxCentre?.lng,
limit: 8,
sources: 'index,osm',
lang,
});
if (found.length > 0) return { suggestions: indexSuggestions(found, lang), source: 'trek-places' };
} catch (err: unknown) {
console.warn('TREK Places autocomplete failed, falling back:', (err as Error).message);
}).catch((err: unknown) => {
console.warn('TREK Places autocomplete failed, falling back:', (err as Error).message);
return [];
}),
this.osm.autocompletePhoton(input, lang, boxCentre),
]);
const suggestions = mergeSuggestions(indexSuggestions(found, lang), photon ?? []);
if (suggestions.length > 0) {
return { suggestions, source: found.length > 0 ? 'trek-places' : 'openstreetmap' };
}
}
+11 -1
View File
@@ -48,7 +48,7 @@ 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 } from './photon.mapping';
import { asNominatimHit, asNominatimReverse, asSuggestion } from './photon.mapping';
import { readWikiIdentity } from './wiki-identity';
import { Injectable } from '@nestjs/common';
import { normalizePlaceWebsite } from '@trek/shared';
@@ -526,6 +526,16 @@ export class OsmClient {
}
}
/**
* Suggestions from Photon, which is built for search-as-you-type, or null when
* it is not in use or did not answer. Never Nominatim in its place: its usage
* policy rules out autocomplete.
*/
async autocompletePhoton(input: string, lang?: string, bias?: { lat: number; lng: number }) {
const found = await this.photon.search(input, { lang, bias });
return found?.map(asSuggestion).filter((row) => row !== null) ?? null;
}
/**
* Finds the OpenStreetMap record for a place we only know by name and
* coordinate, and hands back its tags.
@@ -10,6 +10,7 @@
*/
import { toApiLang } from '../maps.helpers';
import type { NominatimResult, NominatimReverse } from './osm.client';
import type { MapsAutocompleteResult } from '@trek/shared';
import { z } from 'zod';
@@ -74,6 +75,7 @@ export const photonAnswerSchema = z.object({ features: z.array(photonFeatureSche
export type PhotonFeature = z.infer<typeof photonFeatureSchema>;
type PhotonProperties = PhotonFeature['properties'];
type Suggestion = MapsAutocompleteResult['suggestions'][number];
/**
* The language Photon is asked in: TREK's code through toApiLang (`br` is
@@ -133,6 +135,33 @@ export function asNominatimHit(feature: PhotonFeature): NominatimResult | null {
};
}
/**
* A search hit as one row of the suggestion list, in the id form the details
* lookup reads (`node:123`, as the index's OpenStreetMap layer hands it over),
* or null for one without an id or a name to show.
*/
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.
const name = feature.properties.name?.trim();
const line = hit.display_name;
const [head = '', ...rest] = line.split(', ');
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(', '),
source: 'openstreetmap',
lat,
lng,
};
}
/**
* 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
@@ -1,5 +1,5 @@
/**
* MAPS-AUTO-001..016: the suggestion list behind the place search box.
* MAPS-AUTO-001..024: the suggestion list behind the place search box.
*
* This is the path the index was built for. Nominatim's usage policy names
* autocomplete as unacceptable use in its own words, whatever the rate, and the
@@ -13,7 +13,8 @@ import type { AppSettingsRepository } from '../../../src/db/repositories/AppSett
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { noGoogleQuota } from '../../helpers/google-quota';
import { buildMapsService } from '../../helpers/maps-service';
import { buildMapsParts, buildMapsService } from '../../helpers/maps-service';
import type { MapsAutocompleteResult } from '@trek/shared';
import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -47,6 +48,8 @@ vi.mock('../../../src/app-config', async (importOriginal) => {
};
});
type Suggestion = MapsAutocompleteResult['suggestions'][number];
const INPUT = 'Café Kröpel';
const hit = (over: Record<string, unknown> = {}) => ({
@@ -88,12 +91,44 @@ const osmHit = (over: Record<string, unknown> = {}) => ({
*/
function make(enabled = true, rows: Record<string, string> = {}) {
trekPlaces.on = enabled;
const appSettings = {
return buildMapsService(
{} as PlacePhotoCacheService,
settings(rows),
noUsers,
{} as never,
{} as never,
noGoogleQuota,
);
}
function settings(rows: Record<string, string>): AppSettingsRepository {
return {
getValue: async (key: string) => (rows[key] !== undefined ? rows[key] : null),
} as unknown as AppSettingsRepository;
return buildMapsService({} as PlacePhotoCacheService, appSettings, noUsers, {} as never, {} as never, noGoogleQuota);
}
/**
* The service with Photon answering `rows` (null: the services are off). The
* client itself is pinned in osm.photon.test.ts; here only what the keystroke
* path does with its answer matters.
*/
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);
return { svc: parts.svc, photon };
}
const photonRow = (over: Partial<Suggestion> = {}): Suggestion => ({
placeId: 'way:518071791',
mainText: 'Kröpeliner Tor',
secondaryText: 'Kröpeliner Straße, Rostock, Deutschland',
source: 'openstreetmap',
lat: 54.0893,
lng: 12.1325,
...over,
});
/** Google's autocomplete envelope, which is not the shape its text search answers in. */
const googleSuggestions = (name: string) => ({
ok: true,
@@ -429,4 +464,56 @@ describe('MapsService.autocompletePlaces', () => {
expect(suggestions[0].secondaryText).toBe('Somewhere');
});
it('MAPS-AUTO-021: Photon is asked beside the index and its places join the list (#2877)', async () => {
mockSearch.mockResolvedValue([hit(), hit({ gers: 'def-456', name: 'Café Central', lat: 54.2, lng: 12.2 })]);
const { svc, photon } = withPhoton([
photonRow(),
photonRow({ placeId: 'node:7', mainText: 'Kröpeliner Straße', lat: 54.09, lng: 12.135 }),
]);
const bias = { low: { lat: 54, lng: 12 }, high: { lat: 54.2, lng: 12.2 } };
const result = await svc.autocompletePlaces(1, INPUT, 'de', bias);
expect(photon).toHaveBeenCalledWith(INPUT, 'de', { lat: 54.1, lng: 12.1 });
expect(result.source).toBe('trek-places');
expect(result.suggestions.map((s) => s.placeId)).toEqual([
'gers:abc-123',
'way:518071791',
'gers:def-456',
'node:7',
]);
expect(result.suggestions[1]).toMatchObject({ source: 'openstreetmap', mainText: 'Kröpeliner Tor' });
});
it('MAPS-AUTO-022: what only Photon found is the answer when the index has nothing, named as OpenStreetMap', async () => {
mockSearch.mockResolvedValue([]);
const { svc } = withPhoton([photonRow()]);
const result = await svc.autocompletePlaces(1, INPUT);
expect(result).toEqual({ suggestions: [photonRow()], source: 'openstreetmap' });
});
it('MAPS-AUTO-023: an index failure keeps what Photon found instead of throwing it away', async () => {
mockSearch.mockRejectedValue(new Error('places api down'));
const { svc } = withPhoton([photonRow()]);
const result = await svc.autocompletePlaces(1, INPUT);
expect(console.warn).toHaveBeenCalledWith('TREK Places autocomplete failed, falling back:', 'places api down');
expect(result.suggestions).toEqual([photonRow()]);
});
it('MAPS-AUTO-024: the Google-only switch skips Photon the way it skips the index', async () => {
const fetchMock = vi.fn().mockResolvedValue(googleSuggestions('Tokyo Station'));
vi.stubGlobal('fetch', fetchMock);
const { svc, photon } = withPhoton([photonRow()], { maps_api_key: 'key', places_google_only: 'true' });
const result = await svc.autocompletePlaces(1, INPUT);
expect(photon).not.toHaveBeenCalled();
expect(result.source).toBe('google');
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,61 @@
/**
* MAPS-SUGMERGE-001..004: 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
* each side, and one list must show it once while still showing what only
* Photon found.
*/
import { mergeSuggestions } from '../../../src/nest/maps/maps-index.helpers';
import { describe, it, expect } from 'vitest';
const row = (placeId: string, mainText: string, lat: number, lng: number, source: string) => ({
placeId,
mainText,
secondaryText: '',
source,
lat,
lng,
});
describe('mergeSuggestions', () => {
it('MAPS-SUGMERGE-001: interleaves the two, the index leading', () => {
const index = [
row('gers:a', 'Café Kranzler', 52.5, 13.33, 'trek-places'),
row('gers:b', 'Café Central', 52.6, 13.4, 'trek-places'),
];
const photon = [
row('way:1', 'Kranzler Eck', 52.503, 13.333, 'openstreetmap'),
row('node:2', 'Kranzlersee', 53, 13, 'openstreetmap'),
];
expect(mergeSuggestions(index, photon).map((s) => s.placeId)).toEqual(['gers:a', 'way:1', 'gers:b', 'node:2']);
});
it("MAPS-SUGMERGE-002: drops Photon's copy of an element the layer already listed", () => {
const index = [row('node:9712313', 'Tokio Hauptbahnhof', 35.6811816, 139.76598265, 'openstreetmap')];
// Photon names it in a different language, so only the id says it is one place.
const photon = [row('node:9712313', 'Tokyo Station', 35.6812, 139.7671, 'openstreetmap')];
expect(mergeSuggestions(index, photon)).toEqual(index);
});
it('MAPS-SUGMERGE-003: drops a Photon row for a place the index lists by the search-list rule', () => {
const index = [row('gers:a', 'Brandenburger Tor', 52.5163, 13.3777, 'trek-places')];
const photon = [
row('way:518071791', 'Brandenburger Tor', 52.51628, 13.37771, 'openstreetmap'),
row('node:3', 'Hotel Adlon', 52.5164, 13.3779, 'openstreetmap'),
];
// The hotel next to the gate shares the spot and nothing else, so it stays.
expect(mergeSuggestions(index, photon).map((s) => s.placeId)).toEqual(['gers:a', 'node:3']);
});
it('MAPS-SUGMERGE-004: stops at the limit, and answers from Photon alone when the index found nothing', () => {
const photon = Array.from({ length: 10 }, (_, i) => row(`node:${i}`, `Place ${i}`, i, i, 'openstreetmap'));
expect(mergeSuggestions([], photon)).toHaveLength(8);
expect(mergeSuggestions([], photon, 3).map((s) => s.placeId)).toEqual(['node:0', 'node:1', 'node:2']);
});
});
+26 -1
View File
@@ -1,5 +1,5 @@
/**
* OSM-PHOTON-001..012: the OpenStreetMap search and reverse lookup ask Photon
* 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
@@ -136,6 +136,31 @@ describe('search', () => {
},
]);
});
it('OSM-PHOTON-013: the keystroke beside the index asks Photon alone, with the coordinates it found (#2877)', async () => {
photonReply = () => photonAnswer([gateFeature(), gateFeature({ osm_id: undefined })]);
const suggestions = await osm.autocompletePhoton('Brandenb', 'de', { lat: 52.5, lng: 13.4 });
expect(hosts).toEqual(['photon.liketrek.com']);
expect(suggestions).toEqual([
{
placeId: 'way:518071791',
mainText: 'Brandenburger Tor',
secondaryText: 'Pariser Platz, Mitte, Berlin, 10117, Deutschland',
source: 'openstreetmap',
lat: 52.5163,
lng: 13.3777,
},
]);
});
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();
photonReply = () => photonAnswer([], 503);
identity.bearer = 'tk1.kid.payload.signature';
expect(await osm.autocompletePhoton('Brandenb', 'de')).toBeNull();
expect(hosts).toEqual(['photon.liketrek.com']);
});
});
describe('reverse', () => {
+36 -2
View File
@@ -1,10 +1,11 @@
/**
* PHOTON-MAP-001..013: Photon's answers read into the shape Nominatim gives,
* and the language Photon is asked in.
* 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.
*/
import {
asNominatimHit,
asNominatimReverse,
asSuggestion,
photonAnswerSchema,
photonDisplayName,
photonLang,
@@ -121,3 +122,36 @@ describe('asNominatimReverse', () => {
expect(asNominatimReverse(bare.features[0])).toEqual({ display_name: undefined, name: undefined, address: {} });
});
});
describe('asSuggestion', () => {
it('PHOTON-MAP-014: titles the row by its name, with where it is below, in the id form the details lookup reads', () => {
expect(asSuggestion(feature())).toEqual({
placeId: 'way:518071791',
mainText: 'Brandenburger Tor',
secondaryText: 'Pariser Platz, Mitte, Berlin, 10117, Deutschland',
source: 'openstreetmap',
lat: 52.5163,
lng: 13.3777,
});
});
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');
const house = asSuggestion(
feature({ name: undefined, housenumber: '77', street: 'Unter den Linden', postcode: undefined }),
);
expect(house?.mainText).toBe('77');
expect(house?.secondaryText).toBe('Unter den Linden, Mitte, Berlin, Deutschland');
});
it('PHOTON-MAP-016: has no row for a hit without an id or anything to call it', () => {
expect(asSuggestion(feature({ osm_id: undefined }))).toBeNull();
const bare = photonAnswerSchema.parse({
features: [{ geometry: { coordinates: [13.4, 52.5] }, properties: { osm_type: 'N', osm_id: 1 } }],
});
expect(asSuggestion(bare.features[0])).toBeNull();
});
});