mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 20:41:25 +02:00
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:
parent
f5ad492371
commit
a47a7bfdba
21 changed files with 1519 additions and 34 deletions
32
services/mana-geocoding/src/app.ts
Normal file
32
services/mana-geocoding/src/app.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* App factory — separated from index.ts so tests can import without
|
||||
* triggering the production bootstrap.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Config } from './config';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createGeocodeRoutes } from './routes/geocode';
|
||||
|
||||
export function createApp(config: Config): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ error: 'internal_error' }, 500);
|
||||
});
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: config.cors.origins,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
app.route('/health', healthRoutes);
|
||||
app.route('/api/v1/geocode', createGeocodeRoutes(config));
|
||||
|
||||
return app;
|
||||
}
|
||||
36
services/mana-geocoding/src/config.ts
Normal file
36
services/mana-geocoding/src/config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Application configuration loaded from environment variables.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
pelias: {
|
||||
/** Pelias API base URL (the API container, not the placeholder service) */
|
||||
apiUrl: string;
|
||||
};
|
||||
cors: {
|
||||
origins: string[];
|
||||
};
|
||||
cache: {
|
||||
/** Max entries in the in-memory LRU cache */
|
||||
maxEntries: number;
|
||||
/** TTL in milliseconds (default: 24h — geocoding results rarely change) */
|
||||
ttlMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3018', 10),
|
||||
pelias: {
|
||||
apiUrl: process.env.PELIAS_API_URL || 'http://localhost:4000/v1',
|
||||
},
|
||||
cors: {
|
||||
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
|
||||
},
|
||||
cache: {
|
||||
maxEntries: parseInt(process.env.CACHE_MAX_ENTRIES || '5000', 10),
|
||||
ttlMs: parseInt(process.env.CACHE_TTL_MS || String(24 * 60 * 60 * 1000), 10),
|
||||
},
|
||||
};
|
||||
}
|
||||
20
services/mana-geocoding/src/index.ts
Normal file
20
services/mana-geocoding/src/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* mana-geocoding — Self-hosted geocoding proxy.
|
||||
*
|
||||
* Wraps a local Pelias instance with caching and OSM → PlaceCategory
|
||||
* mapping. All geocoding queries stay within our infrastructure —
|
||||
* no user location data leaves the network.
|
||||
*/
|
||||
|
||||
import { createApp } from './app';
|
||||
import { loadConfig } from './config';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
console.log(`mana-geocoding starting on port ${config.port}...`);
|
||||
console.log(`Pelias API: ${config.pelias.apiUrl}`);
|
||||
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: createApp(config).fetch,
|
||||
};
|
||||
56
services/mana-geocoding/src/lib/cache.ts
Normal file
56
services/mana-geocoding/src/lib/cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
170
services/mana-geocoding/src/lib/category-map.ts
Normal file
170
services/mana-geocoding/src/lib/category-map.ts
Normal 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';
|
||||
}
|
||||
210
services/mana-geocoding/src/routes/geocode.ts
Normal file
210
services/mana-geocoding/src/routes/geocode.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Geocoding routes — thin proxy to Pelias with caching and
|
||||
* OSM category mapping.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/v1/geocode/search?q=...&limit=5 — forward (autocomplete)
|
||||
* GET /api/v1/geocode/reverse?lat=...&lon=... — reverse
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Config } from '../config';
|
||||
import { LRUCache } from '../lib/cache';
|
||||
import { mapOsmToPlaceCategory, type PlaceCategory } from '../lib/category-map';
|
||||
|
||||
/** Normalized result returned to the client */
|
||||
export interface GeocodingResult {
|
||||
/** Display name (e.g. "Münster Café, Münsterplatz 3, Konstanz") */
|
||||
label: string;
|
||||
/** Short name (e.g. "Münster Café") */
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
/** Structured address components */
|
||||
address: {
|
||||
street?: string;
|
||||
houseNumber?: string;
|
||||
postalCode?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
/** Our Places category, derived from OSM tags */
|
||||
category: PlaceCategory;
|
||||
/** Raw OSM category key (e.g. "amenity") */
|
||||
osmCategory?: string;
|
||||
/** Raw OSM type value (e.g. "restaurant") */
|
||||
osmType?: string;
|
||||
/** Pelias confidence score 0-1 */
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export function createGeocodeRoutes(config: Config) {
|
||||
const app = new Hono();
|
||||
const searchCache = new LRUCache<GeocodingResult[]>(config.cache.maxEntries, config.cache.ttlMs);
|
||||
const reverseCache = new LRUCache<GeocodingResult[]>(config.cache.maxEntries, config.cache.ttlMs);
|
||||
|
||||
/**
|
||||
* Forward geocoding / autocomplete
|
||||
* GET /search?q=Münsterplatz+Konstanz&limit=5&lang=de
|
||||
*/
|
||||
app.get('/search', async (c) => {
|
||||
const q = c.req.query('q');
|
||||
if (!q || q.trim().length < 2) {
|
||||
return c.json({ results: [] });
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(c.req.query('limit') || '5', 10), 20);
|
||||
const lang = c.req.query('lang') || 'de';
|
||||
const focusLat = c.req.query('focus.lat');
|
||||
const focusLon = c.req.query('focus.lon');
|
||||
|
||||
const cacheKey = `${q}|${limit}|${lang}|${focusLat}|${focusLon}`;
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return c.json({ results: cached, cached: true });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
text: q.trim(),
|
||||
size: String(limit),
|
||||
lang,
|
||||
'boundary.country': 'DEU,AUT,CHE',
|
||||
});
|
||||
|
||||
// Bias results towards a focus point (user's current location)
|
||||
if (focusLat && focusLon) {
|
||||
params.set('focus.point.lat', focusLat);
|
||||
params.set('focus.point.lon', focusLon);
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.pelias.apiUrl}/autocomplete?${params}`);
|
||||
if (!response.ok) {
|
||||
console.error(`Pelias autocomplete error: ${response.status} ${response.statusText}`);
|
||||
return c.json({ results: [], error: 'geocoding_unavailable' }, 502);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PeliasResponse;
|
||||
const results = data.features.map(normalizePeliasFeature);
|
||||
|
||||
searchCache.set(cacheKey, results);
|
||||
return c.json({ results });
|
||||
});
|
||||
|
||||
/**
|
||||
* Reverse geocoding
|
||||
* GET /reverse?lat=47.663&lon=9.175&lang=de
|
||||
*/
|
||||
app.get('/reverse', async (c) => {
|
||||
const lat = c.req.query('lat');
|
||||
const lon = c.req.query('lon');
|
||||
if (!lat || !lon) {
|
||||
return c.json({ error: 'lat and lon are required' }, 400);
|
||||
}
|
||||
|
||||
const lang = c.req.query('lang') || 'de';
|
||||
|
||||
// Round to 5 decimal places (~1m precision) for cache hits
|
||||
const roundedLat = parseFloat(lat).toFixed(5);
|
||||
const roundedLon = parseFloat(lon).toFixed(5);
|
||||
const cacheKey = `${roundedLat}|${roundedLon}|${lang}`;
|
||||
|
||||
const cached = reverseCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return c.json({ results: cached, cached: true });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
'point.lat': roundedLat,
|
||||
'point.lon': roundedLon,
|
||||
size: '3',
|
||||
lang,
|
||||
});
|
||||
|
||||
const response = await fetch(`${config.pelias.apiUrl}/reverse?${params}`);
|
||||
if (!response.ok) {
|
||||
console.error(`Pelias reverse error: ${response.status} ${response.statusText}`);
|
||||
return c.json({ results: [], error: 'geocoding_unavailable' }, 502);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PeliasResponse;
|
||||
const results = data.features.map(normalizePeliasFeature);
|
||||
|
||||
reverseCache.set(cacheKey, results);
|
||||
return c.json({ results });
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache stats (for monitoring)
|
||||
* GET /stats
|
||||
*/
|
||||
app.get('/stats', (c) => {
|
||||
return c.json({
|
||||
searchCacheSize: searchCache.size,
|
||||
reverseCacheSize: reverseCache.size,
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// --- Pelias response types ---
|
||||
|
||||
interface PeliasResponse {
|
||||
type: 'FeatureCollection';
|
||||
features: PeliasFeature[];
|
||||
}
|
||||
|
||||
interface PeliasFeature {
|
||||
type: 'Feature';
|
||||
geometry: {
|
||||
type: 'Point';
|
||||
coordinates: [number, number]; // [lon, lat]
|
||||
};
|
||||
properties: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
confidence?: number;
|
||||
layer?: string;
|
||||
street?: string;
|
||||
housenumber?: string;
|
||||
postalcode?: string;
|
||||
locality?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
addendum?: {
|
||||
osm?: {
|
||||
category?: string;
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePeliasFeature(feature: PeliasFeature): GeocodingResult {
|
||||
const props = feature.properties;
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
|
||||
const osmCategory = props.addendum?.osm?.category;
|
||||
const osmType = props.addendum?.osm?.type;
|
||||
|
||||
return {
|
||||
label: props.label || props.name || '',
|
||||
name: props.name || '',
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
address: {
|
||||
street: props.street,
|
||||
houseNumber: props.housenumber,
|
||||
postalCode: props.postalcode,
|
||||
city: props.locality,
|
||||
state: props.region,
|
||||
country: props.country,
|
||||
},
|
||||
category: mapOsmToPlaceCategory(osmCategory, osmType, props.layer),
|
||||
osmCategory,
|
||||
osmType,
|
||||
confidence: props.confidence ?? 0,
|
||||
};
|
||||
}
|
||||
5
services/mana-geocoding/src/routes/health.ts
Normal file
5
services/mana-geocoding/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoutes = new Hono();
|
||||
|
||||
healthRoutes.get('/', (c) => c.json({ status: 'ok', service: 'mana-geocoding' }));
|
||||
Loading…
Add table
Add a link
Reference in a new issue