mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 19:46:42 +02:00
chore(geocoding): remove Pelias + close 3 bypass paths to public Nominatim
Pelias was retired from the Mac mini on 2026-04-28; photon-self (self-hosted Photon on mana-gpu) has been the live primary since then. This removes the now-dead Pelias adapter, config, tests, and the services/mana-geocoding/pelias/ stack — the entire compose file, the geojsonify_place_details.js patch, the setup.sh import script. Provider chain is now `photon-self → photon → nominatim`. The chain keeps its `privacy: 'local' | 'public'` split, sensitive-query blocking, coord quantization, and aggressive caching unchanged. Three direct calls to nominatim.openstreetmap.org that bypassed mana-geocoding now route through the wrapper: - citycorners/add-city + citycorners/cities/[slug]/add use the shared searchAddress() client (browser → same-origin proxy → mana-geocoding → photon-self). - memoro mobile drops its OSM reverse-geocoding fallback entirely; Expo's on-device reverse-geocoding stays as the sole path. Routing through the wrapper would require a memoro-server proxy endpoint — a follow-up if Expo's quality proves insufficient. Other behavioral changes: - CACHE_PUBLIC_TTL_MS dropped from 7d → 1h. The long TTL was a privacy-amplification trick from the Pelias era; with photon-self serving the bulk of traffic, a transient cross-LAN blip was pinning cached fallback answers for days. 1h gives quick recovery. - /health/pelias renamed to /health/photon-self; prometheus blackbox config + status-page generator updated. - mana-geocoding container no longer needs `extra_hosts: host.docker.internal:host-gateway` (was only there for the Pelias-on-host-network era). 113 tests passing. CLAUDE.md rewritten to reflect the post-Pelias architecture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7bca16dfa7
commit
2bbcf14aba
35 changed files with 330 additions and 1262 deletions
|
|
@ -68,7 +68,7 @@ const SEARCH: SearchRequest = { q: 'test', limit: 5, lang: 'de' };
|
|||
|
||||
describe('ProviderChain — happy path', () => {
|
||||
it('returns the first provider that succeeds', async () => {
|
||||
const a = new FakeProvider('pelias');
|
||||
const a = new FakeProvider('photon-self');
|
||||
const b = new FakeProvider('photon');
|
||||
const chain = new ProviderChain({
|
||||
providers: [a, b],
|
||||
|
|
@ -76,29 +76,29 @@ describe('ProviderChain — happy path', () => {
|
|||
});
|
||||
const res = await chain.search(SEARCH);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.provider).toBe('pelias');
|
||||
expect(res.tried).toEqual(['pelias']);
|
||||
expect(res.provider).toBe('photon-self');
|
||||
expect(res.tried).toEqual(['photon-self']);
|
||||
expect(a.calls.search).toBe(1);
|
||||
expect(b.calls.search).toBe(0);
|
||||
});
|
||||
|
||||
it('honors the providers array order', async () => {
|
||||
const photon = new FakeProvider('photon');
|
||||
const pelias = new FakeProvider('pelias');
|
||||
const local = new FakeProvider('photon-self');
|
||||
// photon first this time
|
||||
const chain = new ProviderChain({
|
||||
providers: [photon, pelias],
|
||||
providers: [photon, local],
|
||||
healthCacheMs: 60_000,
|
||||
});
|
||||
const res = await chain.search(SEARCH);
|
||||
expect(res.provider).toBe('photon');
|
||||
expect(pelias.calls.search).toBe(0);
|
||||
expect(local.calls.search).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProviderChain — failover', () => {
|
||||
it('falls through on unreachable, returns next provider', async () => {
|
||||
const a = new FakeProvider('pelias', {
|
||||
const a = new FakeProvider('photon-self', {
|
||||
search: async () => ({ ok: false, kind: 'unreachable', status: 503 }),
|
||||
});
|
||||
const b = new FakeProvider('photon');
|
||||
|
|
@ -106,7 +106,7 @@ describe('ProviderChain — failover', () => {
|
|||
const res = await chain.search(SEARCH);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.provider).toBe('photon');
|
||||
expect(res.tried).toEqual(['pelias', 'photon']);
|
||||
expect(res.tried).toEqual(['photon-self', 'photon']);
|
||||
});
|
||||
|
||||
it('falls through on rate_limited', async () => {
|
||||
|
|
@ -121,20 +121,20 @@ describe('ProviderChain — failover', () => {
|
|||
|
||||
it('STOPS on empty results — does not consume fallback budget', async () => {
|
||||
// A clean empty answer is definitive: don't burn through public APIs.
|
||||
const a = new FakeProvider('pelias', {
|
||||
const a = new FakeProvider('photon-self', {
|
||||
search: async () => ({ ok: true, results: [] }),
|
||||
});
|
||||
const b = new FakeProvider('photon');
|
||||
const chain = new ProviderChain({ providers: [a, b], healthCacheMs: 60_000 });
|
||||
const res = await chain.search(SEARCH);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.provider).toBe('pelias');
|
||||
expect(res.provider).toBe('photon-self');
|
||||
expect(res.results).toEqual([]);
|
||||
expect(b.calls.search).toBe(0);
|
||||
});
|
||||
|
||||
it('returns ok:false when all providers fail', async () => {
|
||||
const a = new FakeProvider('pelias', {
|
||||
const a = new FakeProvider('photon-self', {
|
||||
search: async () => ({ ok: false, kind: 'unreachable' }),
|
||||
});
|
||||
const b = new FakeProvider('photon', {
|
||||
|
|
@ -144,23 +144,23 @@ describe('ProviderChain — failover', () => {
|
|||
const res = await chain.search(SEARCH);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.results).toEqual([]);
|
||||
expect(res.tried).toEqual(['pelias', 'photon']);
|
||||
expect(res.tried).toEqual(['photon-self', 'photon']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProviderChain — health cache', () => {
|
||||
it('skips a provider whose health probe returned false', async () => {
|
||||
const dead = new FakeProvider('pelias', { health: async () => false });
|
||||
const dead = new FakeProvider('photon-self', { health: async () => false });
|
||||
const alive = new FakeProvider('photon');
|
||||
const chain = new ProviderChain({ providers: [dead, alive], healthCacheMs: 60_000 });
|
||||
const res = await chain.search(SEARCH);
|
||||
expect(res.tried).toEqual(['photon']); // pelias was skipped, not tried
|
||||
expect(res.tried).toEqual(['photon']); // local was skipped, not tried
|
||||
expect(dead.calls.search).toBe(0);
|
||||
expect(dead.calls.health).toBe(1);
|
||||
});
|
||||
|
||||
it('caches health for healthCacheMs — only one probe per window', async () => {
|
||||
const a = new FakeProvider('pelias');
|
||||
const a = new FakeProvider('photon-self');
|
||||
const chain = new ProviderChain({ providers: [a], healthCacheMs: 60_000 });
|
||||
await chain.search(SEARCH);
|
||||
await chain.search(SEARCH);
|
||||
|
|
@ -171,18 +171,19 @@ describe('ProviderChain — health cache', () => {
|
|||
|
||||
it('marks provider unhealthy when search fails, skipping it next time', async () => {
|
||||
let failNext = true;
|
||||
const flaky = new FakeProvider('pelias', {
|
||||
search: async () => (failNext ? { ok: false, kind: 'unreachable' } : okResults('pelias')),
|
||||
const flaky = new FakeProvider('photon-self', {
|
||||
search: async () =>
|
||||
failNext ? { ok: false, kind: 'unreachable' } : okResults('photon-self'),
|
||||
});
|
||||
const alive = new FakeProvider('photon');
|
||||
const chain = new ProviderChain({ providers: [flaky, alive], healthCacheMs: 60_000 });
|
||||
|
||||
// First call: pelias fails → cached unhealthy → photon serves
|
||||
// First call: local fails → cached unhealthy → photon serves
|
||||
const r1 = await chain.search(SEARCH);
|
||||
expect(r1.provider).toBe('photon');
|
||||
expect(r1.tried).toEqual(['pelias', 'photon']);
|
||||
expect(r1.tried).toEqual(['photon-self', 'photon']);
|
||||
|
||||
// Second call: pelias is in unhealthy cache, not tried at all
|
||||
// Second call: local is in unhealthy cache, not tried at all
|
||||
failNext = false; // would now succeed but never gets called
|
||||
const r2 = await chain.search(SEARCH);
|
||||
expect(r2.provider).toBe('photon');
|
||||
|
|
@ -191,7 +192,7 @@ describe('ProviderChain — health cache', () => {
|
|||
});
|
||||
|
||||
it('refreshes health after cache expires', async () => {
|
||||
const dead = new FakeProvider('pelias', { health: async () => false });
|
||||
const dead = new FakeProvider('photon-self', { health: async () => false });
|
||||
const alive = new FakeProvider('photon');
|
||||
// 1ms cache for fast test
|
||||
const chain = new ProviderChain({ providers: [dead, alive], healthCacheMs: 1 });
|
||||
|
|
@ -203,7 +204,7 @@ describe('ProviderChain — health cache', () => {
|
|||
});
|
||||
|
||||
it('clearHealthCache forces re-probe', async () => {
|
||||
const a = new FakeProvider('pelias');
|
||||
const a = new FakeProvider('photon-self');
|
||||
const chain = new ProviderChain({ providers: [a], healthCacheMs: 60_000 });
|
||||
await chain.search(SEARCH);
|
||||
expect(a.calls.health).toBe(1);
|
||||
|
|
@ -215,19 +216,19 @@ describe('ProviderChain — health cache', () => {
|
|||
|
||||
describe('ProviderChain — getHealthSnapshot', () => {
|
||||
it('reports per-provider health + age', async () => {
|
||||
const ok = new FakeProvider('pelias');
|
||||
const ok = new FakeProvider('photon-self');
|
||||
const dead = new FakeProvider('photon', { health: async () => false });
|
||||
const chain = new ProviderChain({ providers: [ok, dead], healthCacheMs: 60_000 });
|
||||
await chain.search(SEARCH);
|
||||
const snap = chain.getHealthSnapshot();
|
||||
expect(snap).toHaveLength(2);
|
||||
expect(snap[0]).toMatchObject({ name: 'pelias', healthy: true });
|
||||
expect(snap[0]).toMatchObject({ name: 'photon-self', healthy: true });
|
||||
expect(snap[1]).toMatchObject({ name: 'photon', healthy: false });
|
||||
expect(snap[0].ageMs).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('reports Infinity age for never-probed providers', async () => {
|
||||
const a = new FakeProvider('pelias');
|
||||
const a = new FakeProvider('photon-self');
|
||||
const chain = new ProviderChain({ providers: [a], healthCacheMs: 60_000 });
|
||||
const snap = chain.getHealthSnapshot();
|
||||
expect(snap[0].ageMs).toBe(Infinity);
|
||||
|
|
@ -237,7 +238,7 @@ describe('ProviderChain — getHealthSnapshot', () => {
|
|||
|
||||
describe('ProviderChain — reverse', () => {
|
||||
it('uses the same provider order for reverse', async () => {
|
||||
const a = new FakeProvider('pelias', {
|
||||
const a = new FakeProvider('photon-self', {
|
||||
reverse: async () => ({ ok: false, kind: 'unreachable' }),
|
||||
});
|
||||
const b = new FakeProvider('photon', { privacy: 'public' });
|
||||
|
|
@ -251,26 +252,26 @@ describe('ProviderChain — reverse', () => {
|
|||
|
||||
describe('ProviderChain — privacy / localOnly mode', () => {
|
||||
it('skips public providers when localOnly is true', async () => {
|
||||
const localPelias = new FakeProvider('pelias', { privacy: 'local' });
|
||||
const localProvider = new FakeProvider('photon-self', { privacy: 'local' });
|
||||
const publicPhoton = new FakeProvider('photon', { privacy: 'public' });
|
||||
const publicNominatim = new FakeProvider('nominatim', { privacy: 'public' });
|
||||
const chain = new ProviderChain({
|
||||
providers: [localPelias, publicPhoton, publicNominatim],
|
||||
providers: [localProvider, publicPhoton, publicNominatim],
|
||||
healthCacheMs: 60_000,
|
||||
});
|
||||
|
||||
const res = await chain.search(SEARCH, undefined, { localOnly: true });
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.provider).toBe('pelias');
|
||||
expect(localPelias.calls.search).toBe(1);
|
||||
expect(res.provider).toBe('photon-self');
|
||||
expect(localProvider.calls.search).toBe(1);
|
||||
// Public providers must not even have their search() called
|
||||
expect(publicPhoton.calls.search).toBe(0);
|
||||
expect(publicNominatim.calls.search).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to the second LOCAL provider when the first local fails', async () => {
|
||||
const local1 = new FakeProvider('pelias', {
|
||||
const local1 = new FakeProvider('photon-self', {
|
||||
privacy: 'local',
|
||||
search: async () => ({ ok: false, kind: 'unreachable' }),
|
||||
});
|
||||
|
|
@ -313,7 +314,7 @@ describe('ProviderChain — privacy / localOnly mode', () => {
|
|||
});
|
||||
|
||||
it('returns notice: fallback_used when a public provider serves a non-sensitive query', async () => {
|
||||
const localDown = new FakeProvider('pelias', {
|
||||
const localDown = new FakeProvider('photon-self', {
|
||||
privacy: 'local',
|
||||
health: async () => false,
|
||||
});
|
||||
|
|
@ -329,10 +330,10 @@ describe('ProviderChain — privacy / localOnly mode', () => {
|
|||
});
|
||||
|
||||
it('NO notice when the local provider serves a non-sensitive query', async () => {
|
||||
const localUp = new FakeProvider('pelias', { privacy: 'local' });
|
||||
const localUp = new FakeProvider('photon-self', { privacy: 'local' });
|
||||
const chain = new ProviderChain({ providers: [localUp], healthCacheMs: 60_000 });
|
||||
const res = await chain.search(SEARCH);
|
||||
expect(res.provider).toBe('pelias');
|
||||
expect(res.provider).toBe('photon-self');
|
||||
expect(res.notice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Tests for normalizing Nominatim's flat-JSON shape into our GeocodingResult.
|
||||
*
|
||||
* Nominatim differs from Photon/Pelias in three subtle ways we lock in:
|
||||
* Nominatim differs from Photon in three subtle ways we lock in:
|
||||
* 1. Lat/lon are STRINGS, not numbers — the normalizer must parseFloat.
|
||||
* 2. Display name is a comma-noisy hierarchy ("Konzil, Hafenstraße,
|
||||
* Konstanz, Konstanz, Regierungsbezirk Freiburg, Baden-Württemberg,
|
||||
|
|
@ -135,16 +135,4 @@ describe('normalizeNominatimResult', () => {
|
|||
});
|
||||
expect(result.provider).toBe('nominatim');
|
||||
});
|
||||
|
||||
it('does not set peliasCategories', () => {
|
||||
// Consumer side keys off the absence of this field as a "fallback
|
||||
// provider" signal.
|
||||
const result = normalizeNominatimResult({
|
||||
lat: '47.0',
|
||||
lon: '9.0',
|
||||
class: 'amenity',
|
||||
type: 'restaurant',
|
||||
});
|
||||
expect(result.peliasCategories).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,8 +44,6 @@ describe('normalizePhotonFeature', () => {
|
|||
});
|
||||
expect(result.confidence).toBeCloseTo(0.78, 2);
|
||||
expect(result.provider).toBe('photon');
|
||||
// peliasCategories deliberately absent for non-Pelias providers
|
||||
expect(result.peliasCategories).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds label from structured fields', () => {
|
||||
|
|
@ -111,7 +109,7 @@ describe('normalizePhotonFeature', () => {
|
|||
});
|
||||
|
||||
it('coordinates: Photon emits [lon, lat] — normalizer must NOT swap', () => {
|
||||
// Catches the all-too-easy lon/lat flip when porting from Pelias.
|
||||
// Catches the all-too-easy lon/lat flip in Photon's GeoJSON.
|
||||
const result = normalizePhotonFeature({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [9.1758, 47.6634] },
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export type ChainNotice =
|
|||
/** Sensitive query was blocked from public providers and no local
|
||||
* provider was healthy → no results, but the absence is intentional. */
|
||||
| 'sensitive_local_unavailable'
|
||||
/** A non-Pelias provider served the request (Pelias was down). */
|
||||
/** A public provider served the request (the local provider was down). */
|
||||
| 'fallback_used';
|
||||
|
||||
export interface ChainOptions {
|
||||
|
|
@ -161,9 +161,9 @@ export class ProviderChain {
|
|||
}
|
||||
|
||||
// Stale or missing — refresh. We don't await this aggressively in
|
||||
// happy paths (Pelias up + healthy is the cheapest case), but on
|
||||
// cold-start every entry is missing so the first request pays for
|
||||
// one health probe per provider.
|
||||
// happy paths (photon-self up + healthy is the cheapest case),
|
||||
// but on cold-start every entry is missing so the first request
|
||||
// pays for one health probe per provider.
|
||||
const healthy = await provider.health(signal);
|
||||
this.health.set(provider.name, { healthy, checkedAt: now });
|
||||
if (!healthy) {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
* search/reverse. A custom `User-Agent` is required (Nominatim returns
|
||||
* 403 to default-UA fetches).
|
||||
*
|
||||
* Compared to Pelias/Photon, Nominatim returns a single flat array
|
||||
* rather than GeoJSON. We adapt the shape and synthesize a confidence
|
||||
* score from `importance`.
|
||||
* Unlike Photon, Nominatim returns a single flat array rather than
|
||||
* GeoJSON. We adapt the shape and synthesize a confidence score from
|
||||
* `importance`.
|
||||
*
|
||||
* https://nominatim.org/release-docs/develop/api/Search/
|
||||
* https://operations.osmfoundation.org/policies/nominatim/
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
/**
|
||||
* Pelias provider — primary backend, self-hosted with the DACH OSM index.
|
||||
*
|
||||
* Forward-search uses /autocomplete first (fast venue match) and falls
|
||||
* back to /search if autocomplete returns zero features (autocomplete
|
||||
* deliberately excludes the address layer for perf).
|
||||
*/
|
||||
|
||||
import { mapPeliasToPlaceCategory } from '../lib/category-map';
|
||||
import type {
|
||||
GeocodingProvider,
|
||||
GeocodingResult,
|
||||
ProviderResponse,
|
||||
ReverseRequest,
|
||||
SearchRequest,
|
||||
} from './types';
|
||||
|
||||
export interface PeliasConfig {
|
||||
apiUrl: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export class PeliasProvider implements GeocodingProvider {
|
||||
readonly name = 'pelias' as const;
|
||||
readonly privacy = 'local' as const;
|
||||
|
||||
constructor(private readonly config: PeliasConfig) {}
|
||||
|
||||
async search(req: SearchRequest, signal?: AbortSignal): Promise<ProviderResponse> {
|
||||
const params = new URLSearchParams({
|
||||
text: req.q.trim(),
|
||||
size: String(req.limit),
|
||||
lang: req.lang,
|
||||
});
|
||||
if (req.focusLat && req.focusLon) {
|
||||
params.set('focus.point.lat', req.focusLat);
|
||||
params.set('focus.point.lon', req.focusLon);
|
||||
}
|
||||
|
||||
// /autocomplete first (fast venue match), then /search if empty.
|
||||
// Both attempts are wrapped in the same external timeout signal so
|
||||
// a cumulative slow Pelias still falls through to the next provider.
|
||||
try {
|
||||
const ac = await this.fetch(`/autocomplete?${params}`, signal);
|
||||
if (!ac.ok) return { ok: false, kind: 'unreachable', status: ac.status };
|
||||
let features = ac.features;
|
||||
|
||||
if (features.length === 0) {
|
||||
const s = await this.fetch(`/search?${params}`, signal);
|
||||
if (s.ok) features = s.features;
|
||||
// /search returning a non-OK after /autocomplete returned OK-but-empty
|
||||
// is a clean zero-results answer, not a fall-through. We trust the
|
||||
// successful autocomplete probe.
|
||||
}
|
||||
|
||||
return { ok: true, results: features.map(normalizePeliasFeature) };
|
||||
} catch (e) {
|
||||
return { ok: false, kind: 'unreachable', error: errorMessage(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async reverse(req: ReverseRequest, signal?: AbortSignal): Promise<ProviderResponse> {
|
||||
const params = new URLSearchParams({
|
||||
'point.lat': req.lat,
|
||||
'point.lon': req.lon,
|
||||
size: '3',
|
||||
lang: req.lang,
|
||||
});
|
||||
|
||||
try {
|
||||
const r = await this.fetch(`/reverse?${params}`, signal);
|
||||
if (!r.ok) return { ok: false, kind: 'unreachable', status: r.status };
|
||||
return { ok: true, results: r.features.map(normalizePeliasFeature) };
|
||||
} catch (e) {
|
||||
return { ok: false, kind: 'unreachable', error: errorMessage(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async health(signal?: AbortSignal): Promise<boolean> {
|
||||
try {
|
||||
const url = `${this.config.apiUrl}/status`;
|
||||
const res = await fetch(url, {
|
||||
signal: combineSignals(signal, AbortSignal.timeout(this.config.timeoutMs)),
|
||||
});
|
||||
// /v1/status doesn't exist on every Pelias version — a 404 still
|
||||
// means the server is up. Anything else (5xx, ECONNREFUSED, timeout)
|
||||
// is unhealthy.
|
||||
return res.ok || res.status === 404;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ ok: boolean; status: number; features: PeliasFeature[] }> {
|
||||
const res = await fetch(`${this.config.apiUrl}${path}`, {
|
||||
signal: combineSignals(signal, AbortSignal.timeout(this.config.timeoutMs)),
|
||||
});
|
||||
if (!res.ok) return { ok: false, status: res.status, features: [] };
|
||||
const data = (await res.json()) as PeliasResponse;
|
||||
return { ok: true, status: res.status, features: data.features ?? [] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pelias native 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;
|
||||
category?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePeliasFeature(feature: PeliasFeature): GeocodingResult {
|
||||
const props = feature.properties;
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
|
||||
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: mapPeliasToPlaceCategory(props.category, props.layer),
|
||||
peliasCategories: props.category,
|
||||
confidence: props.confidence ?? 0,
|
||||
provider: 'pelias',
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/** Combine an external AbortSignal with our own timeout signal. AbortSignal.any
|
||||
* exists in Bun but TS typing is patchy across runtimes — small helper. */
|
||||
function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
|
||||
const real = signals.filter((s): s is AbortSignal => !!s);
|
||||
if (real.length === 1) return real[0];
|
||||
const ctrl = new AbortController();
|
||||
for (const s of real) {
|
||||
if (s.aborted) {
|
||||
ctrl.abort(s.reason);
|
||||
break;
|
||||
}
|
||||
s.addEventListener('abort', () => ctrl.abort(s.reason), { once: true });
|
||||
}
|
||||
return ctrl.signal;
|
||||
}
|
||||
|
|
@ -5,15 +5,10 @@
|
|||
* importer). The HTTP shape is GeoJSON FeatureCollection with `properties`
|
||||
* holding `osm_key`/`osm_value` raw OSM tags + structured address fields.
|
||||
*
|
||||
* Compared to Pelias:
|
||||
* + No rate limit advertised, but be a polite neighbor: short timeouts,
|
||||
* no retries, cache aggressively.
|
||||
* + Reverse geocoding takes lon/lat (note the order — different from
|
||||
* Pelias's point.lat/point.lon). Easy to flip if not careful.
|
||||
* - No `confidence` field. We approximate from `importance` (0–1) when
|
||||
* present, else 0.5 as a neutral default.
|
||||
* - No DACH-specific tuning — German venue names sometimes lose umlauts
|
||||
* in display labels. Acceptable for a fallback.
|
||||
* Same class powers both `photon-self` (self-hosted, privacy: 'local')
|
||||
* and `photon` (public komoot.io, privacy: 'public'). Reverse-geocoding
|
||||
* takes lon/lat (note the order). Confidence is approximated from
|
||||
* `importance` (0–1) when present, else 0.5 as a neutral default.
|
||||
*/
|
||||
|
||||
import { mapOsmTagToPlaceCategory } from '../lib/osm-category-map';
|
||||
|
|
@ -207,9 +202,6 @@ export function normalizePhotonFeature(
|
|||
country: props.country,
|
||||
},
|
||||
category,
|
||||
// peliasCategories deliberately omitted — Photon has osm_key:osm_value
|
||||
// but the consumer side keys off the absence of this field as a
|
||||
// "result came from a fallback" signal.
|
||||
confidence: typeof props.importance === 'number' ? props.importance : 0.5,
|
||||
provider: providerName,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,12 +29,8 @@ export interface GeocodingResult {
|
|||
};
|
||||
/** Our Places category, derived from the provider's native taxonomy. */
|
||||
category: PlaceCategory;
|
||||
/** Raw Pelias categories (food, retail, transport, …) — only present
|
||||
* when the result came from Pelias. Photon/Nominatim don't have an
|
||||
* equivalent multi-tag taxonomy. */
|
||||
peliasCategories?: string[];
|
||||
/** Confidence score 0–1. Pelias provides this natively; Photon/Nominatim
|
||||
* approximate it from `importance`. */
|
||||
/** Confidence score 0–1. Photon/Nominatim approximate it from
|
||||
* `importance`. */
|
||||
confidence: number;
|
||||
/** Which provider answered — useful for telemetry + UI hints
|
||||
* ("approximate match" badge for fallback providers). */
|
||||
|
|
@ -42,8 +38,8 @@ export interface GeocodingResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Provider identifiers. Two of these wrap the same `PhotonProvider`
|
||||
* class with different configs:
|
||||
* Provider identifiers. `photon-self` and `photon` both wrap the same
|
||||
* `PhotonProvider` class with different configs:
|
||||
*
|
||||
* - `photon-self`: self-hosted Photon (typically on mana-gpu),
|
||||
* `privacy: 'local'`. Eligible for sensitive queries.
|
||||
|
|
@ -55,7 +51,7 @@ export interface GeocodingResult {
|
|||
* tracks per-provider health. A single `photon` slot can't simultaneously
|
||||
* mean two different backends.
|
||||
*/
|
||||
export type ProviderName = 'pelias' | 'photon-self' | 'photon' | 'nominatim';
|
||||
export type ProviderName = 'photon-self' | 'photon' | 'nominatim';
|
||||
|
||||
export interface SearchRequest {
|
||||
q: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue