fix(maps): category pills ask the index by Overture path, near misses are left out

The index matches a term anywhere inside a place's category text, so the
bare names behind the pills caught whatever was spelled like them: "park"
brought car parks and an amusement park, "garden" furniture stores and beer
gardens, "bar" barbers, "pub" publishers and public squares, "market"
marketing agencies. In Vienna four of sixty "Nature & parks" pins were
parks. Every pill now names its Overture categories by full path, a place
counts only when its path is that path or lies under it, and a page of
near misses only falls through to Overpass. Terms Overture no longer has
(nightclub, nature_preserve, shopping_center ...) got their current names.

Closes #2663
This commit is contained in:
Maurice
2026-10-18 12:27:12 +02:00
parent 6463060f67
commit fc631d36b6
5 changed files with 205 additions and 45 deletions
+24 -16
View File
@@ -9,6 +9,7 @@ import { buildOsmDetails, parsePoiCategories, samePlace, toApiLang } from './map
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
import {
POI_CATEGORY_TO_TREK,
indexRowTerm,
isOsmHit,
osmPlaceId,
toPlaceRecord,
@@ -55,22 +56,15 @@ export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): In
// by Overpass, and a mixed answer would silently drop it.
const indexKnowsAll = wanted.length > 0 && wanted.every((key) => POI_CATEGORY_TO_TREK[key]?.length);
if (!indexKnowsAll) return null;
// The index matches a term as a SUBSTRING of `category` and `category_path`
// (see POI_CATEGORY_TO_TREK), so an exact lookup misses every leaf that is
// not literally a term: `italian_restaurant` is a hit for `restaurant` and
// finds nothing here. Falling through to wanted[0] then labelled it with
// whichever pill the user happened to tap first, and the corridor panel
// groups and colours on exactly that field, so a trattoria came back as a
// petrol station. Longest match wins, because `fast_food` must not lose to
// `food` when both are terms of different categories.
// A row belongs to the term whose path it lies under, not to one it is merely
// spelled like: the leaf alone is rarely a term (`italian_restaurant` sits
// under `food_and_drink>restaurant`), and falling through to wanted[0] then
// labelled it with whichever pill the user tapped first, so a trattoria came
// back as a petrol station. The most specific term wins, so a water park
// answers the activity pill rather than the park it sits under.
const labelFor = (leaf: string | null, path: string | null): string | undefined => {
const haystack = `${leaf ?? ''} ${path ?? ''}`;
let best: string | undefined;
for (const term of categoryOfTerm.keys()) {
if (!haystack.includes(term)) continue;
if (best === undefined || term.length > best.length) best = term;
}
return best === undefined ? undefined : categoryOfTerm.get(best);
const term = indexRowTerm(categoryOfTerm.keys(), leaf, path);
return term === undefined ? undefined : categoryOfTerm.get(term);
};
const lat = (bbox.south + bbox.north) / 2;
const lng = (bbox.west + bbox.east) / 2;
@@ -109,7 +103,7 @@ export function indexPoiAnswer(
): PoiSearchResult {
const { wanted, labelFor, cap, radius, reach } = plan;
return {
pois: found.map((p) => ({
pois: belonging(found, plan).map((p) => ({
osm_id: `gers:${p.gers}`,
name: p.name,
lat: p.lat,
@@ -138,6 +132,8 @@ export function indexPoiAnswer(
source: 'trek-places' as const,
})),
source: 'trek-places' as const,
// The page the index sent, not what was left of it: a full page means more
// places, however many of them this one had to leave out.
truncated: found.length >= cap,
// A wide viewport is narrowed here too, and the caller is told so
// for the same reason the Overpass path tells it.
@@ -145,6 +141,18 @@ export function indexPoiAnswer(
};
}
/**
* The rows that belong to a pill the request asked for. The index matches its
* terms inside the category text, so a near miss can still come back, and it
* is left out here (#2663). A row the index holds without any category cannot
* be told either way and stays, as it always has.
*/
export function belonging<T extends TrekPlace & { categoryPath?: string | null }>(found: T[], plan: IndexPoiPlan): T[] {
return found.filter(
(p) => (!p.category && !p.categoryPath) || plan.labelFor(p.category ?? null, p.categoryPath ?? null) !== undefined,
);
}
const regionNamesByLang = new Map<string, Intl.DisplayNames | null>();
/**
+3 -2
View File
@@ -4,7 +4,7 @@ import type { AppSettingsRepository } from '../../db/repositories/AppSettings.re
import type { GeoLane } from '../geo/nominatim.client';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import type { ApiKeySource } from '../settings/instance-api-keys';
import { indexPoiAnswer, indexPoiPlan, indexSuggestions } from './maps-index.helpers';
import { belonging, indexPoiAnswer, indexPoiPlan, indexSuggestions } 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';
import { toApiLang, mergeSearchResults } from './maps.helpers';
@@ -214,7 +214,8 @@ export class MapsService {
limit: plan.cap,
category: plan.terms.join(','),
});
if (found.length > 0) return indexPoiAnswer(found, plan);
// A page of near misses only is a miss too, not an empty answer.
if (belonging(found, plan).length > 0) return indexPoiAnswer(found, plan);
} catch (err: unknown) {
console.warn('TREK Places nearby failed, falling back:', (err as Error).message);
}
+56 -17
View File
@@ -345,31 +345,70 @@ export function indexHitsOnly(hits: TrekSearchHit[]): TrekPlace[] {
*
* Deliberately a hand-written map rather than a guess at runtime: the two
* taxonomies do not line up, and a pill that quietly returns the wrong kind of
* place is worse than one that returns none. Each entry is a substring matched
* against `category` and `category_path`, so `eat_and_drink>cafe` answers the
* cafe pill without listing every leaf.
* place is worse than one that returns none. Each entry is a category path, and
* a place belongs to it when its own path is that path or lies under it, so
* `food_and_drink>restaurant` answers the italian restaurant two levels down.
*
* Paths, not bare leaf names: the index matches a term anywhere inside
* `category` and `category_path`, and a bare `park` brought car parks, `garden`
* furniture stores and `bar` barbers (#2663). A full path does not occur inside
* an unrelated one, and {@link indexRowTerm} drops whatever still slips through.
* Overture renames a branch now and then; a path that stops existing answers
* nothing, and the pill falls back to Overpass instead of answering wrong.
*/
export const POI_CATEGORY_TO_TREK: Record<string, string[]> = {
restaurant: ['restaurant', 'casual_eatery', 'fast_food'],
cafe: ['cafe', 'coffee_shop'],
bar: ['bar', 'pub', 'nightclub', 'brewery'],
hotel: ['hotel', 'lodging', 'hostel', 'motel', 'bed_and_breakfast'],
sights: ['historic_site', 'landmark_and_historical_building', 'monument', 'castle', 'tourist_attraction'],
museum: ['museum', 'art_gallery', 'performing_arts', 'theater'],
nature: ['park', 'garden', 'beach', 'nature_preserve', 'mountain'],
activity: ['amusement_park', 'zoo', 'aquarium', 'water_park', 'theme_park'],
shopping: ['shopping', 'shopping_center', 'department_store', 'market'],
supermarket: ['grocery_store', 'food_and_beverage_store', 'convenience_store', 'supermarket'],
restaurant: ['food_and_drink>restaurant', 'food_and_drink>casual_eatery'],
cafe: ['food_and_drink>casual_eatery>cafe', 'food_and_drink>non_alcoholic_beverage_venue>coffee_shop'],
bar: ['food_and_drink>alcoholic_beverage_venue', 'arts_and_entertainment>nightlife_venue'],
hotel: ['lodging'],
sights: ['cultural_and_historic>historic_site', 'cultural_and_historic>memorial_site>monument'],
museum: [
'arts_and_entertainment>museum',
'arts_and_entertainment>arts_and_crafts_space>art_gallery',
'arts_and_entertainment>performing_arts_venue',
'arts_and_entertainment>movie_theater',
],
nature: [
'sports_and_recreation>park',
'geographic_entities>built_feature>garden',
'geographic_entities>land_feature>beach',
'geographic_entities>land_feature>nature_reserve',
'geographic_entities>land_feature>mountain',
],
activity: [
'arts_and_entertainment>amusement_attraction>amusement_park',
'arts_and_entertainment>animal_attraction>zoo',
'arts_and_entertainment>animal_attraction>aquarium',
'sports_and_recreation>park>water_park',
],
shopping: ['shopping'],
supermarket: ['shopping>food_and_beverage_store', 'shopping>convenience_store'],
// What a drive needs rather than what a city visit does (#1797). The corridor
// search asks for these by name, and without them every corridor query fell
// through to Overpass — the one path where that hurts most, because a single
// search fans out over sixteen boxes.
fuel: ['gas_station', 'fueling_station'],
charging: ['ev_charging_station'],
rest_area: ['rest_stop'],
campsite: ['campground', 'rv_park'],
fuel: ['travel_and_transportation>fueling_station>gas_station'],
charging: ['travel_and_transportation>fueling_station>ev_charging_station'],
rest_area: ['travel_and_transportation>ground_transport_facility_or_service>road_structures_and_service>rest_stop'],
campsite: ['lodging>campground', 'lodging>rv_park'],
};
/**
* The term of {@link POI_CATEGORY_TO_TREK} a row belongs to, the most specific
* one when several fit, or undefined when none does. A row is judged by its path
* where it has one and by its leaf where the index left the path empty.
*/
export function indexRowTerm(terms: Iterable<string>, leaf: string | null, path: string | null): string | undefined {
let best: string | undefined;
for (const term of terms) {
const fits = path
? path === term || path.startsWith(`${term}>`)
: !!leaf && leaf === term.slice(term.lastIndexOf('>') + 1);
if (fits && (best === undefined || term.length > best.length)) best = term;
}
return best;
}
export interface TrekNearbyPlace extends TrekPlace {
distanceMetres: number;
}