lecheminvert.tech/server/utils/geocoding.ts
Adam Belkacemi cf6ee64e31
Some checks are pending
CI / tests (push) Waiting to run
CI / deploiement (push) Blocked by required conditions
Import initial : code en production sur Hetzner
2026-09-07 18:21:38 +00:00

82 lines
No EOL
2.7 KiB
TypeScript

import type { Place } from '#shared/types'
const UA = 'lecheminvert.tech (adam@belkacemi.me)'
interface BanResponse {
features: Array<{
geometry: { coordinates: [number, number] }
properties: { label: string }
}>
}
interface PhotonResponse {
features: Array<{
geometry: { coordinates: [number, number] }
properties: {
name?: string
city?: string
country?: string
street?: string
housenumber?: string
}
}>
}
/** La BAN est excellente en France, et aveugle partout ailleurs. */
async function fromBan(q: string): Promise<Place[]> {
const url = `https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(q)}&limit=5&autocomplete=1`
const data = await $fetch<BanResponse>(url, { headers: { 'User-Agent': UA } })
return data.features.map(f => ({
label: f.properties.label,
lon: f.geometry.coordinates[0],
lat: f.geometry.coordinates[1],
}))
}
/** Photon couvre le monde entier via OpenStreetMap, sans clé. */
async function fromPhoton(q: string): Promise<Place[]> {
const url = `https://photon.komoot.io/api/?q=${encodeURIComponent(q)}&limit=5&lang=fr`
const data = await $fetch<PhotonResponse>(url, { headers: { 'User-Agent': UA } })
return data.features.map((f) => {
const p = f.properties
const rue = [p.housenumber, p.street].filter(Boolean).join(' ')
const parts = [rue || p.name, p.city !== p.name ? p.city : undefined, p.country]
return {
label: parts.filter(Boolean).join(', '),
lon: f.geometry.coordinates[0],
lat: f.geometry.coordinates[1],
}
})
}
/** Deux entrees sont identiques si elles partagent le nom ou la position. */
export function dedupe(places: Place[]): Place[] {
const seenLabel = new Set<string>()
const seenCoord = new Set<string>()
return places.filter((p) => {
const label = p.label.toLowerCase().trim()
const coord = `${p.lat.toFixed(2)},${p.lon.toFixed(2)}`
if (seenLabel.has(label) || seenCoord.has(coord)) return false
seenLabel.add(label)
seenCoord.add(coord)
return true
})
}
/**
* Les deux sources sont entrelacees plutot que concatenees : sans cela,
* les cinq resultats francais de la BAN enterrent systematiquement la
* vraie ville etrangere trouvee par Photon.
*/
export async function searchPlaces(q: string): Promise<Place[]> {
const [ban, photon] = await Promise.allSettled([fromBan(q), fromPhoton(q)])
const a = ban.status === 'fulfilled' ? ban.value : []
const b = photon.status === 'fulfilled' ? photon.value : []
const merged: Place[] = []
for (let i = 0; i < Math.max(a.length, b.length); i++) {
if (a[i]) merged.push(a[i]!)
if (b[i]) merged.push(b[i]!)
}
return dedupe(merged).slice(0, 8)
}