mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +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
|
|
@ -7,7 +7,8 @@
|
|||
import { useAllPlaces } from './queries';
|
||||
import { placesStore } from './stores/places.svelte';
|
||||
import { trackingStore } from './stores/tracking.svelte';
|
||||
import { Star, MapPin, Plus, PencilSimple, Trash } from '@mana/shared-icons';
|
||||
import { searchAddress, formatAddress, type GeocodingResult } from './geocoding';
|
||||
import { Star, MapPin, Plus, PencilSimple, Trash, MagnifyingGlass } from '@mana/shared-icons';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
|
||||
|
|
@ -54,7 +55,58 @@
|
|||
other: 'Sonstiges',
|
||||
};
|
||||
|
||||
// Quick create
|
||||
// --- Address autocomplete ---
|
||||
let addressQuery = $state('');
|
||||
let suggestions = $state<GeocodingResult[]>([]);
|
||||
let showSuggestions = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function onAddressInput() {
|
||||
clearTimeout(debounceTimer);
|
||||
if (addressQuery.trim().length < 2) {
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const focusLat = trackingStore.currentPosition?.coords.latitude;
|
||||
const focusLon = trackingStore.currentPosition?.coords.longitude;
|
||||
suggestions = await searchAddress(addressQuery, {
|
||||
limit: 6,
|
||||
focusLat,
|
||||
focusLon,
|
||||
});
|
||||
showSuggestions = suggestions.length > 0;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function selectSuggestion(result: GeocodingResult) {
|
||||
showSuggestions = false;
|
||||
addressQuery = '';
|
||||
const place = await placesStore.createPlace({
|
||||
name: result.name || result.label,
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
address: formatAddress(result.address),
|
||||
category: result.category,
|
||||
});
|
||||
navigate('detail', { placeId: place.id });
|
||||
}
|
||||
|
||||
function onAddressKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
showSuggestions = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAddressBlur() {
|
||||
// Delay to allow click on suggestion
|
||||
setTimeout(() => {
|
||||
showSuggestions = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Quick create (manual name)
|
||||
let newName = $state('');
|
||||
|
||||
async function createPlace() {
|
||||
|
|
@ -157,12 +209,51 @@
|
|||
<input class="search-input" type="text" placeholder="Orte suchen..." bind:value={search} />
|
||||
</div>
|
||||
|
||||
<!-- Quick Create -->
|
||||
<!-- Address Search (Geocoding) -->
|
||||
<div class="address-search">
|
||||
<div class="address-input-row">
|
||||
<MagnifyingGlass size={14} class="address-icon" />
|
||||
<input
|
||||
class="address-input"
|
||||
type="text"
|
||||
placeholder="Adresse suchen..."
|
||||
bind:value={addressQuery}
|
||||
oninput={onAddressInput}
|
||||
onkeydown={onAddressKeydown}
|
||||
onblur={onAddressBlur}
|
||||
onfocus={() => {
|
||||
if (suggestions.length > 0) showSuggestions = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if showSuggestions}
|
||||
<div class="suggestions">
|
||||
{#each suggestions as result}
|
||||
<button class="suggestion-item" onclick={() => selectSuggestion(result)}>
|
||||
<div class="suggestion-icon">
|
||||
<MapPin size={14} />
|
||||
</div>
|
||||
<div class="suggestion-info">
|
||||
<span class="suggestion-name">{result.name || result.label}</span>
|
||||
<span class="suggestion-address">{formatAddress(result.address)}</span>
|
||||
</div>
|
||||
{#if result.category !== 'other'}
|
||||
<span class="suggestion-category"
|
||||
>{CATEGORY_LABELS[result.category] ?? result.category}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick Create (manual) -->
|
||||
<div class="create-row">
|
||||
<input
|
||||
class="create-input"
|
||||
type="text"
|
||||
placeholder="Neuen Ort erstellen..."
|
||||
placeholder="Oder manuell erstellen..."
|
||||
bind:value={newName}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
|
|
@ -344,6 +435,120 @@
|
|||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* ── Address Search ───────────────────────── */
|
||||
.address-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.address-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.address-input-row:focus-within {
|
||||
border-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.address-input-row :global(.address-icon) {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.address-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.address-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.suggestion-item + .suggestion-item {
|
||||
border-top: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
color: #0ea5e9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestion-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suggestion-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestion-address {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestion-category {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #0ea5e9;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Quick Create ─────────────────────────── */
|
||||
.create-row {
|
||||
display: flex;
|
||||
|
|
|
|||
117
apps/mana/apps/web/src/lib/modules/places/geocoding.ts
Normal file
117
apps/mana/apps/web/src/lib/modules/places/geocoding.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Geocoding client for the Places module.
|
||||
*
|
||||
* Talks to our self-hosted mana-geocoding service (Pelias-backed).
|
||||
* All queries stay within our infrastructure — no location data
|
||||
* leaves the network.
|
||||
*/
|
||||
|
||||
import type { PlaceCategory } from './types';
|
||||
|
||||
const GEOCODING_URL = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const injected = (window as unknown as { __PUBLIC_MANA_GEOCODING_URL__?: string })
|
||||
.__PUBLIC_MANA_GEOCODING_URL__;
|
||||
if (injected) return injected;
|
||||
}
|
||||
return import.meta.env.PUBLIC_MANA_GEOCODING_URL ?? 'http://localhost:3018';
|
||||
};
|
||||
|
||||
export interface GeocodingResult {
|
||||
label: string;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: {
|
||||
street?: string;
|
||||
houseNumber?: string;
|
||||
postalCode?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
category: PlaceCategory;
|
||||
osmCategory?: string;
|
||||
osmType?: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface GeocodingResponse {
|
||||
results: GeocodingResult[];
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward geocoding / autocomplete.
|
||||
* Returns places matching the search query, biased towards the focus point.
|
||||
*/
|
||||
export async function searchAddress(
|
||||
query: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
focusLat?: number;
|
||||
focusLon?: number;
|
||||
lang?: string;
|
||||
}
|
||||
): Promise<GeocodingResult[]> {
|
||||
if (!query || query.trim().length < 2) return [];
|
||||
|
||||
const params = new URLSearchParams({ q: query.trim() });
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.lang) params.set('lang', options.lang);
|
||||
if (options?.focusLat != null) params.set('focus.lat', String(options.focusLat));
|
||||
if (options?.focusLon != null) params.set('focus.lon', String(options.focusLon));
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/search?${params}`);
|
||||
if (!res.ok) return [];
|
||||
const data: GeocodingResponse = await res.json();
|
||||
return data.results;
|
||||
} catch {
|
||||
console.warn('Geocoding search failed — service may be offline');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse geocoding — resolve coordinates to an address and place type.
|
||||
*/
|
||||
export async function reverseGeocode(
|
||||
lat: number,
|
||||
lon: number,
|
||||
lang = 'de'
|
||||
): Promise<GeocodingResult | null> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(lat),
|
||||
lon: String(lon),
|
||||
lang,
|
||||
});
|
||||
const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/reverse?${params}`);
|
||||
if (!res.ok) return null;
|
||||
const data: GeocodingResponse = await res.json();
|
||||
return data.results[0] ?? null;
|
||||
} catch {
|
||||
console.warn('Reverse geocoding failed — service may be offline');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a structured address into a single-line string.
|
||||
*/
|
||||
export function formatAddress(address: GeocodingResult['address']): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (address.street) {
|
||||
parts.push(address.houseNumber ? `${address.street} ${address.houseNumber}` : address.street);
|
||||
}
|
||||
if (address.postalCode && address.city) {
|
||||
parts.push(`${address.postalCode} ${address.city}`);
|
||||
} else if (address.city) {
|
||||
parts.push(address.city);
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
|
@ -16,4 +16,6 @@ export {
|
|||
findNearestPlace,
|
||||
} from './queries';
|
||||
export { placeTable, locationLogTable, PLACES_GUEST_SEED } from './collections';
|
||||
export { searchAddress, reverseGeocode, formatAddress } from './geocoding';
|
||||
export type { GeocodingResult } from './geocoding';
|
||||
export type { LocalPlace, LocalLocationLog, Place, LocationLog, PlaceCategory } from './types';
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { decryptRecords, encryptRecord } from '$lib/data/crypto';
|
|||
import { createBlock } from '$lib/data/time-blocks/service';
|
||||
import { locationLogTable, placeTable } from '../collections';
|
||||
import { getDistanceKm, findNearestPlace, toPlace } from '../queries';
|
||||
import { reverseGeocode, formatAddress } from '../geocoding';
|
||||
import type { LocalLocationLog, LocalPlace } from '../types';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────
|
||||
|
|
@ -139,11 +140,30 @@ async function logPosition(pos: GeolocationPosition) {
|
|||
if (nearest) {
|
||||
const local = await placeTable.get(nearest.id);
|
||||
if (local) {
|
||||
await placeTable.update(nearest.id, {
|
||||
const updates: Partial<LocalPlace> = {
|
||||
visitCount: (local.visitCount ?? 0) + 1,
|
||||
lastVisitedAt: log.timestamp,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-fill address via reverse geocoding if the place has none
|
||||
if (!local.address) {
|
||||
reverseGeocode(lat, lng).then(async (result) => {
|
||||
if (result) {
|
||||
const addr = formatAddress(result.address);
|
||||
if (addr) {
|
||||
const rec: Partial<LocalPlace> = { address: addr };
|
||||
await encryptRecord('places', rec);
|
||||
await placeTable.update(nearest.id, {
|
||||
address: rec.address,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await placeTable.update(nearest.id, updates);
|
||||
|
||||
await createBlock({
|
||||
startDate: log.timestamp,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
|
||||
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
|
||||
import { placesStore } from '../stores/places.svelte';
|
||||
import { Star, MapPin, X } from '@mana/shared-icons';
|
||||
import { reverseGeocode, formatAddress, searchAddress, type GeocodingResult } from '../geocoding';
|
||||
import { Star, MapPin, X, MagnifyingGlass, ArrowsClockwise } from '@mana/shared-icons';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types';
|
||||
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
|
||||
|
|
@ -64,6 +65,64 @@
|
|||
{ value: 'other', label: 'Sonstiges' },
|
||||
];
|
||||
|
||||
// --- Reverse geocoding (coords → address) ---
|
||||
let isResolving = $state(false);
|
||||
|
||||
async function resolveAddress() {
|
||||
const lat = parseFloat(editLatitude);
|
||||
const lng = parseFloat(editLongitude);
|
||||
if (isNaN(lat) || isNaN(lng) || (lat === 0 && lng === 0)) return;
|
||||
|
||||
isResolving = true;
|
||||
const result = await reverseGeocode(lat, lng);
|
||||
isResolving = false;
|
||||
|
||||
if (result) {
|
||||
editAddress = formatAddress(result.address);
|
||||
if (editCategory === 'other' && result.category !== 'other') {
|
||||
editCategory = result.category;
|
||||
}
|
||||
await saveField();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Address search in detail view ---
|
||||
let addressSearch = $state('');
|
||||
let addressSuggestions = $state<GeocodingResult[]>([]);
|
||||
let showAddressSuggestions = $state(false);
|
||||
let addressDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function onAddressSearchInput() {
|
||||
clearTimeout(addressDebounce);
|
||||
if (addressSearch.trim().length < 2) {
|
||||
addressSuggestions = [];
|
||||
showAddressSuggestions = false;
|
||||
return;
|
||||
}
|
||||
addressDebounce = setTimeout(async () => {
|
||||
addressSuggestions = await searchAddress(addressSearch, { limit: 5 });
|
||||
showAddressSuggestions = addressSuggestions.length > 0;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function applyAddressResult(result: GeocodingResult) {
|
||||
showAddressSuggestions = false;
|
||||
addressSearch = '';
|
||||
editAddress = formatAddress(result.address);
|
||||
editLatitude = String(result.latitude);
|
||||
editLongitude = String(result.longitude);
|
||||
if (result.category !== 'other') {
|
||||
editCategory = result.category;
|
||||
}
|
||||
await saveField();
|
||||
}
|
||||
|
||||
function onAddressSearchBlur() {
|
||||
setTimeout(() => {
|
||||
showAddressSuggestions = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function removeTag(tagId: string) {
|
||||
await removeTagIdWithUndo(detail.entity?.tagIds ?? [], tagId, (next) =>
|
||||
placesStore.updateTagIds(placeId, next)
|
||||
|
|
@ -181,6 +240,37 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Address Search -->
|
||||
<div class="field-row address-search-row">
|
||||
<span class="field-label"></span>
|
||||
<div class="address-search-wrapper">
|
||||
<div class="address-search-input-row">
|
||||
<MagnifyingGlass size={12} />
|
||||
<input
|
||||
class="address-search-input"
|
||||
type="text"
|
||||
placeholder="Adresse suchen..."
|
||||
bind:value={addressSearch}
|
||||
oninput={onAddressSearchInput}
|
||||
onblur={onAddressSearchBlur}
|
||||
onfocus={() => {
|
||||
if (addressSuggestions.length > 0) showAddressSuggestions = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if showAddressSuggestions}
|
||||
<div class="address-suggestions">
|
||||
{#each addressSuggestions as result}
|
||||
<button class="address-suggestion" onclick={() => applyAddressResult(result)}>
|
||||
<span class="address-suggestion-name">{result.name || result.label}</span>
|
||||
<span class="address-suggestion-detail">{formatAddress(result.address)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<span class="field-label">Koordinaten</span>
|
||||
<div class="coords-row">
|
||||
|
|
@ -202,6 +292,14 @@
|
|||
type="number"
|
||||
step="any"
|
||||
/>
|
||||
<button
|
||||
class="resolve-btn"
|
||||
onclick={resolveAddress}
|
||||
disabled={isResolving}
|
||||
title="Adresse aus Koordinaten ermitteln"
|
||||
>
|
||||
<ArrowsClockwise size={14} class={isResolving ? 'spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -359,6 +457,134 @@
|
|||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resolve-btn {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resolve-btn:hover:not(:disabled) {
|
||||
color: #0ea5e9;
|
||||
border-color: hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.resolve-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.resolve-btn :global(.spinning) {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Address Search (Detail) ─────────────── */
|
||||
.address-search-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.address-search-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.address-search-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1875rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.address-search-input-row:focus-within {
|
||||
border-color: hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.address-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.address-search-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.address-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.125rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.address-suggestion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.address-suggestion:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.address-suggestion + .address-suggestion {
|
||||
border-top: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.address-suggestion-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.address-suggestion-detail {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@
|
|||
| 3014 | mana-crawler | Go | Web crawler, content extraction |
|
||||
| 3015 | mana-landing-builder | NestJS | Org landing page builder |
|
||||
| 3016 | mana-api-gateway | Go | API keys, rate limiting, usage tracking |
|
||||
| 3017-3019 | *(reserved)* | | |
|
||||
| 3018 | mana-geocoding | Hono/Bun | Self-hosted geocoding (Pelias proxy) |
|
||||
| 3017, 3019 | *(reserved)* | | |
|
||||
|
||||
## 3020-3029: AI/ML Services
|
||||
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@
|
|||
"dev:calc:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:calc:web\"",
|
||||
"dev:manavoxel:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:manavoxel:web\"",
|
||||
"dev:media": "cd services/mana-media/apps/api && bun run --hot src/index.ts",
|
||||
"dev:geocoding": "cd services/mana-geocoding && bun run --watch src/index.ts",
|
||||
"dev:mana:servers": "concurrently -n auth,sync,api,media -c blue,magenta,yellow,green \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\" \"pnpm dev:media\""
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
76
pnpm-lock.yaml
generated
76
pnpm-lock.yaml
generated
|
|
@ -86,7 +86,7 @@ importers:
|
|||
version: 6.0.154(zod@3.25.76)
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.0
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -105,7 +105,7 @@ importers:
|
|||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: latest
|
||||
version: 1.3.11
|
||||
version: 1.3.12
|
||||
'@types/jsdom':
|
||||
specifier: ^21.1.0
|
||||
version: 21.1.7
|
||||
|
|
@ -2406,7 +2406,7 @@ importers:
|
|||
version: link:../../../../packages/shared-hono
|
||||
drizzle-orm:
|
||||
specifier: ^0.44.7
|
||||
version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
|
||||
version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -2428,7 +2428,7 @@ importers:
|
|||
devDependencies:
|
||||
drizzle-orm:
|
||||
specifier: ^0.44.7
|
||||
version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
|
||||
version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
|
||||
postgres:
|
||||
specifier: ^3.4.7
|
||||
version: 3.4.9
|
||||
|
|
@ -2467,7 +2467,7 @@ importers:
|
|||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: latest
|
||||
version: 1.3.11
|
||||
version: 1.3.12
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
|
|
@ -2854,7 +2854,7 @@ importers:
|
|||
version: link:../shared-logger
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.1
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -3269,7 +3269,7 @@ importers:
|
|||
version: link:../../packages/shared-hono
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -3302,10 +3302,10 @@ importers:
|
|||
version: 3.0.3
|
||||
better-auth:
|
||||
specifier: ^1.4.3
|
||||
version: 1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3)
|
||||
version: 1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3)
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -3344,7 +3344,7 @@ importers:
|
|||
version: 3.0.3
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -3375,7 +3375,7 @@ importers:
|
|||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -3396,6 +3396,16 @@ importers:
|
|||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
services/mana-geocoding:
|
||||
dependencies:
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
services/mana-landing-builder:
|
||||
dependencies:
|
||||
'@mana/shared-types':
|
||||
|
|
@ -3460,7 +3470,7 @@ importers:
|
|||
version: 5.73.0
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
exifr:
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
|
|
@ -3519,7 +3529,7 @@ importers:
|
|||
version: link:../../packages/shared-hono
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -3552,7 +3562,7 @@ importers:
|
|||
version: link:../../packages/shared-hono
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -3580,7 +3590,7 @@ importers:
|
|||
version: 0.5.0
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.12
|
||||
|
|
@ -8490,6 +8500,9 @@ packages:
|
|||
'@types/bun@1.3.11':
|
||||
resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==}
|
||||
|
||||
'@types/bun@1.3.12':
|
||||
resolution: {integrity: sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
|
|
@ -9790,6 +9803,9 @@ packages:
|
|||
bun-types@1.3.11:
|
||||
resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==}
|
||||
|
||||
bun-types@1.3.12:
|
||||
resolution: {integrity: sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==}
|
||||
|
||||
bundle-require@5.1.0:
|
||||
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
@ -19506,12 +19522,12 @@ snapshots:
|
|||
nanostores: 1.2.0
|
||||
zod: 4.3.6
|
||||
|
||||
'@better-auth/drizzle-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))':
|
||||
'@better-auth/drizzle-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.4.0
|
||||
optionalDependencies:
|
||||
drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
|
||||
'@better-auth/kysely-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15)':
|
||||
dependencies:
|
||||
|
|
@ -24746,6 +24762,10 @@ snapshots:
|
|||
dependencies:
|
||||
bun-types: 1.3.11
|
||||
|
||||
'@types/bun@1.3.12':
|
||||
dependencies:
|
||||
bun-types: 1.3.12
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
|
|
@ -26790,10 +26810,10 @@ snapshots:
|
|||
|
||||
bcryptjs@3.0.3: {}
|
||||
|
||||
better-auth@1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3):
|
||||
better-auth@1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))
|
||||
'@better-auth/drizzle-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))
|
||||
'@better-auth/kysely-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15)
|
||||
'@better-auth/memory-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)
|
||||
'@better-auth/mongo-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)
|
||||
|
|
@ -26812,7 +26832,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@sveltejs/kit': 2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
drizzle-kit: 0.30.6
|
||||
drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
svelte: 5.55.1
|
||||
|
|
@ -26971,6 +26991,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 22.19.17
|
||||
|
||||
bun-types@1.3.12:
|
||||
dependencies:
|
||||
'@types/node': 22.19.17
|
||||
|
||||
bundle-require@5.1.0(esbuild@0.27.7):
|
||||
dependencies:
|
||||
esbuild: 0.27.7
|
||||
|
|
@ -27723,30 +27747,30 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0):
|
||||
drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.6.1
|
||||
'@types/react': 19.2.14
|
||||
bun-types: 1.3.11
|
||||
bun-types: 1.3.12
|
||||
kysely: 0.28.15
|
||||
postgres: 3.4.9
|
||||
react: 19.2.0
|
||||
|
||||
drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9):
|
||||
drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.6.1
|
||||
bun-types: 1.3.11
|
||||
bun-types: 1.3.12
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.15
|
||||
postgres: 3.4.9
|
||||
|
||||
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9):
|
||||
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.6.1
|
||||
bun-types: 1.3.11
|
||||
bun-types: 1.3.12
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.15
|
||||
postgres: 3.4.9
|
||||
|
|
|
|||
148
services/mana-geocoding/CLAUDE.md
Normal file
148
services/mana-geocoding/CLAUDE.md
Normal 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
|
||||
```
|
||||
17
services/mana-geocoding/package.json
Normal file
17
services/mana-geocoding/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
107
services/mana-geocoding/pelias/docker-compose.yml
Normal file
107
services/mana-geocoding/pelias/docker-compose.yml
Normal 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
|
||||
39
services/mana-geocoding/pelias/pelias.json
Normal file
39
services/mana-geocoding/pelias/pelias.json
Normal 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"
|
||||
}
|
||||
}
|
||||
35
services/mana-geocoding/pelias/setup.sh
Executable file
35
services/mana-geocoding/pelias/setup.sh
Executable 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"
|
||||
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' }));
|
||||
14
services/mana-geocoding/tsconfig.json
Normal file
14
services/mana-geocoding/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue