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,148 @@
# mana-geocoding
Self-hosted geocoding service. Wraps a local Pelias instance (DACH region) with caching and automatic OSM → PlaceCategory mapping. All geocoding queries stay within our infrastructure — no user location data leaves the network.
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Runtime** | Bun |
| **Framework** | Hono |
| **Geocoding** | Pelias (self-hosted, Elasticsearch-backed) |
| **Data** | OpenStreetMap DACH extract (DE/AT/CH) |
| **Caching** | In-memory LRU (5000 entries, 24h TTL) |
## Port: 3018
## Quick Start
```bash
# 1. Start Pelias stack (first time: run setup.sh for data import)
cd services/mana-geocoding/pelias
docker compose up -d
# First time only:
chmod +x setup.sh && ./setup.sh
# 2. Start the Hono wrapper
cd services/mana-geocoding
bun run dev
```
## API Endpoints
All endpoints are public (no auth required) — the service is internal-only, not exposed to the internet.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/geocode/search?q=...` | Forward geocoding / autocomplete |
| GET | `/api/v1/geocode/reverse?lat=...&lon=...` | Reverse geocoding |
| GET | `/api/v1/geocode/stats` | Cache statistics |
| GET | `/health` | Health check |
### Search params
| Param | Required | Description |
|-------|----------|-------------|
| `q` | yes | Search query (min 2 chars) |
| `limit` | no | Max results (default 5, max 20) |
| `lang` | no | Language (default `de`) |
| `focus.lat` | no | Bias results towards this latitude |
| `focus.lon` | no | Bias results towards this longitude |
### Reverse params
| Param | Required | Description |
|-------|----------|-------------|
| `lat` | yes | Latitude |
| `lon` | yes | Longitude |
| `lang` | no | Language (default `de`) |
### Response format
```json
{
"results": [
{
"label": "Münster Café, Münsterplatz 3, 78462 Konstanz",
"name": "Münster Café",
"latitude": 47.663,
"longitude": 9.175,
"address": {
"street": "Münsterplatz",
"houseNumber": "3",
"postalCode": "78462",
"city": "Konstanz",
"country": "Germany"
},
"category": "food",
"osmCategory": "amenity",
"osmType": "cafe",
"confidence": 0.95
}
]
}
```
## Category Mapping
The service maps OSM tags to our 7 PlaceCategories:
| PlaceCategory | OSM examples |
|---------------|-------------|
| `home` | building:residential, building:house, building:apartments |
| `work` | amenity:school, amenity:university, office:*, building:commercial |
| `food` | amenity:restaurant, amenity:cafe, shop:bakery, shop:supermarket |
| `shopping` | shop:*, amenity:marketplace |
| `transit` | railway:station, highway:bus_stop, amenity:parking, aeroway:* |
| `leisure` | tourism:*, leisure:park, amenity:cinema, sport:* |
| `other` | Everything else |
## Architecture
```
Client (Places module)
→ mana-geocoding (Hono, port 3018)
→ LRU cache check
→ Pelias API (port 4000)
→ Elasticsearch (port 9200)
```
## Configuration
```env
PORT=3018
PELIAS_API_URL=http://localhost:4000/v1
CORS_ORIGINS=http://localhost:5173,https://mana.how
CACHE_MAX_ENTRIES=5000
CACHE_TTL_MS=86400000
```
## Pelias Infrastructure
The Pelias stack runs as a separate docker-compose in `pelias/`:
- **elasticsearch** — Index storage (~500MB for DACH)
- **api** — HTTP API (port 4000)
- **libpostal** — Address parsing (port 4400)
- **Import containers** — Run once for initial data load, then stop
RAM usage (running): ~1.5GB (elasticsearch 512MB + api + libpostal)
## Code Layout
```
src/
├── index.ts # Bootstrap
├── app.ts # Hono app factory
├── config.ts # Environment config
├── routes/
│ ├── geocode.ts # Forward + reverse endpoints with caching
│ └── health.ts
└── lib/
├── cache.ts # LRU cache with TTL
└── category-map.ts # OSM → PlaceCategory mapping
pelias/
├── docker-compose.yml # Pelias stack
├── pelias.json # Pelias config (DACH region)
└── setup.sh # Initial data import script
```

View file

@ -0,0 +1,17 @@
{
"name": "@mana/geocoding",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"test": "bun test"
},
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,107 @@
# Pelias geocoding stack for mana-geocoding.
#
# Data pipeline: download → prepare → import → serve.
# See pelias/README.md for initial setup instructions.
#
# After import, only `api` and `libpostal` need to stay running.
# The import containers (placeholder, interpolation, pip, elasticsearch)
# run during import and can be stopped afterward if RAM is tight,
# but elasticsearch must stay up for queries.
services:
# --- Always running ---
api:
image: pelias/api:latest
container_name: pelias-api
restart: unless-stopped
ports:
- "4000:4000"
environment:
PORT: 4000
volumes:
- ./pelias.json:/code/pelias.json:ro
depends_on:
elasticsearch:
condition: service_healthy
networks:
- pelias
libpostal:
image: pelias/libpostal-service
container_name: pelias-libpostal
restart: unless-stopped
ports:
- "4400:4400"
networks:
- pelias
elasticsearch:
image: pelias/elasticsearch:7.17.1
container_name: pelias-elasticsearch
restart: unless-stopped
ports:
- "9200:9200"
volumes:
- pelias-elasticsearch:/usr/share/elasticsearch/data
environment:
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9200/_cluster/health"]
interval: 10s
timeout: 5s
retries: 30
networks:
- pelias
# --- Import pipeline (run once, then stop) ---
schema:
image: pelias/schema:latest
container_name: pelias-schema
volumes:
- ./pelias.json:/code/pelias.json:ro
depends_on:
elasticsearch:
condition: service_healthy
networks:
- pelias
profiles: ["import"]
openstreetmap:
image: pelias/openstreetmap:latest
container_name: pelias-openstreetmap
volumes:
- ./pelias.json:/code/pelias.json:ro
- pelias-data:/data
depends_on:
elasticsearch:
condition: service_healthy
networks:
- pelias
profiles: ["import"]
polylines:
image: pelias/polylines:latest
container_name: pelias-polylines
volumes:
- ./pelias.json:/code/pelias.json:ro
- pelias-data:/data
depends_on:
elasticsearch:
condition: service_healthy
networks:
- pelias
profiles: ["import"]
volumes:
pelias-elasticsearch:
pelias-data:
networks:
pelias:
driver: bridge

View file

@ -0,0 +1,39 @@
{
"esclient": {
"apiVersion": "7.x",
"hosts": [
{
"host": "elasticsearch",
"port": 9200
}
]
},
"api": {
"services": {
"libpostal": "http://libpostal:4400"
},
"defaultParameters": {
"boundary.country": ["DEU", "AUT", "CHE"]
}
},
"imports": {
"openstreetmap": {
"download": [
{
"sourceURL": "https://download.geofabrik.de/europe/dach-latest.osm.pbf"
}
],
"datapath": "/data/openstreetmap",
"leveldbpath": "/tmp/leveldb",
"importVenues": true,
"importAddresses": true
},
"polylines": {
"datapath": "/data/polylines",
"files": ["extract.0sv"]
}
},
"logger": {
"level": "info"
}
}

View file

@ -0,0 +1,35 @@
#!/bin/bash
# Initial Pelias data import for DACH region.
#
# Run this ONCE after first docker compose up.
# Takes 30-60 minutes depending on hardware.
#
# After import, the "import" profile containers can be stopped.
set -euo pipefail
cd "$(dirname "$0")"
echo "=== Step 1: Create Elasticsearch schema ==="
docker compose --profile import run --rm schema ./bin/create_index
echo "=== Step 2: Download DACH OSM data ==="
mkdir -p data/openstreetmap
docker compose --profile import run --rm openstreetmap ./bin/download
echo "=== Step 3: Import OpenStreetMap data ==="
docker compose --profile import run --rm openstreetmap ./bin/start
echo "=== Step 4: Import polylines (street data) ==="
docker compose --profile import run --rm polylines ./bin/download
docker compose --profile import run --rm polylines ./bin/start
echo ""
echo "=== Import complete! ==="
echo "Pelias API is available at http://localhost:4000/v1"
echo ""
echo "Test it:"
echo " curl 'http://localhost:4000/v1/search?text=Münsterplatz+Konstanz'"
echo " curl 'http://localhost:4000/v1/reverse?point.lat=47.663&point.lon=9.175'"
echo ""
echo "You can now stop the import containers:"
echo " docker compose --profile import stop"

View 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;
}

View 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),
},
};
}

View 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,
};

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';
}

View 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,
};
}

View file

@ -0,0 +1,5 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono();
healthRoutes.get('/', (c) => c.json({ status: 'ok', service: 'mana-geocoding' }));

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["bun-types"]
},
"include": ["src"]
}