feat(places): add self-hosted geocoding with Pelias (DACH)

New mana-geocoding service (port 3018) wraps a self-hosted Pelias
instance with LRU caching and OSM→PlaceCategory auto-mapping.
All geocoding queries stay within our infrastructure — no user
location data leaves the network.

Places module integration:
- Address autocomplete search in ListView (creates place with
  name, coords, address, category in one step)
- Address search + reverse geocoding button in DetailView
- Auto-fill address via reverse geocoding during tracking
- OSM category mapping (amenity:restaurant→food, shop:*→shopping, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 23:02:25 +02:00
parent f5ad492371
commit a47a7bfdba
21 changed files with 1519 additions and 34 deletions

View file

@ -0,0 +1,56 @@
/**
* Simple in-memory LRU cache with TTL for geocoding results.
* Geocoding results rarely change, so we cache aggressively to
* reduce load on the Pelias instance.
*/
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
export class LRUCache<T> {
private map = new Map<string, CacheEntry<T>>();
private maxEntries: number;
private ttlMs: number;
constructor(maxEntries: number, ttlMs: number) {
this.maxEntries = maxEntries;
this.ttlMs = ttlMs;
}
get(key: string): T | undefined {
const entry = this.map.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.map.delete(key);
return undefined;
}
// Move to end (most recently used)
this.map.delete(key);
this.map.set(key, entry);
return entry.value;
}
set(key: string, value: T): void {
// Delete first so re-insert goes to end
this.map.delete(key);
// Evict oldest if at capacity
if (this.map.size >= this.maxEntries) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
this.map.set(key, {
value,
expiresAt: Date.now() + this.ttlMs,
});
}
get size(): number {
return this.map.size;
}
}

View file

@ -0,0 +1,170 @@
/**
* Maps Pelias/OSM categories to our 7 Places categories.
*
* Pelias returns results with `addendum.osm.category` and `addendum.osm.type`
* fields that correspond to OSM key/value pairs. We map these to our simple
* category enum: home, work, food, shopping, transit, leisure, other.
*/
export type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other';
/**
* OSM key PlaceCategory mapping.
* The key is the OSM tag key (e.g. "amenity", "shop"), the value maps
* specific OSM values to our categories. A `_default` entry covers
* any value not explicitly listed.
*/
const OSM_CATEGORY_MAP: Record<
string,
Record<string, PlaceCategory> & { _default?: PlaceCategory }
> = {
amenity: {
_default: 'other',
restaurant: 'food',
cafe: 'food',
fast_food: 'food',
bar: 'food',
pub: 'food',
biergarten: 'food',
food_court: 'food',
ice_cream: 'food',
bakery: 'food',
school: 'work',
university: 'work',
college: 'work',
library: 'work',
coworking_space: 'work',
office: 'work',
bus_station: 'transit',
ferry_terminal: 'transit',
taxi: 'transit',
parking: 'transit',
fuel: 'transit',
bicycle_parking: 'transit',
charging_station: 'transit',
cinema: 'leisure',
theatre: 'leisure',
nightclub: 'leisure',
community_centre: 'leisure',
swimming_pool: 'leisure',
marketplace: 'shopping',
},
shop: {
_default: 'shopping',
supermarket: 'shopping',
bakery: 'food',
butcher: 'food',
deli: 'food',
greengrocer: 'food',
seafood: 'food',
pastry: 'food',
cheese: 'food',
coffee: 'food',
},
tourism: {
_default: 'leisure',
hotel: 'other',
hostel: 'other',
guest_house: 'other',
motel: 'other',
apartment: 'home',
},
leisure: {
_default: 'leisure',
park: 'leisure',
playground: 'leisure',
sports_centre: 'leisure',
fitness_centre: 'leisure',
stadium: 'leisure',
swimming_pool: 'leisure',
garden: 'leisure',
nature_reserve: 'leisure',
beach_resort: 'leisure',
marina: 'leisure',
},
railway: {
_default: 'transit',
station: 'transit',
halt: 'transit',
tram_stop: 'transit',
},
aeroway: {
_default: 'transit',
aerodrome: 'transit',
terminal: 'transit',
},
highway: {
_default: 'transit',
bus_stop: 'transit',
},
building: {
_default: 'other',
residential: 'home',
house: 'home',
apartments: 'home',
detached: 'home',
commercial: 'work',
office: 'work',
industrial: 'work',
retail: 'shopping',
supermarket: 'shopping',
church: 'leisure',
cathedral: 'leisure',
stadium: 'leisure',
school: 'work',
university: 'work',
hospital: 'other',
},
office: {
_default: 'work',
},
sport: {
_default: 'leisure',
},
};
/**
* Derive a PlaceCategory from a Pelias result's OSM metadata.
*
* Pelias provides category info in several fields depending on the data source.
* We check them in order of specificity.
*/
export function mapOsmToPlaceCategory(
osmCategory?: string,
osmType?: string,
peliasLayer?: string
): PlaceCategory {
// Try direct OSM key/value mapping first
if (osmCategory && osmType) {
const categoryMap = OSM_CATEGORY_MAP[osmCategory];
if (categoryMap) {
return categoryMap[osmType] ?? categoryMap._default ?? 'other';
}
}
// Try just the OSM key as a category
if (osmCategory) {
const categoryMap = OSM_CATEGORY_MAP[osmCategory];
if (categoryMap?._default) {
return categoryMap._default;
}
}
// Fallback: use Pelias layer as a hint
if (peliasLayer) {
switch (peliasLayer) {
case 'venue':
return 'other';
case 'address':
case 'street':
return 'other';
case 'neighbourhood':
case 'locality':
case 'region':
case 'country':
return 'other';
}
}
return 'other';
}