Import initial : code en production sur Hetzner
This commit is contained in:
commit
cf6ee64e31
43 changed files with 14404 additions and 0 deletions
82
server/utils/geocoding.ts
Normal file
82
server/utils/geocoding.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue