implement address search and reverse geocoding APIs #5
+316
-33
@@ -13,6 +13,20 @@ interface Emits {
|
|||||||
(e: 'submit', report: CreateReportRequest, media: File[]): void
|
(e: 'submit', report: CreateReportRequest, media: File[]): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GeocodingSuggestion {
|
||||||
|
label: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchAddressesResponse {
|
||||||
|
suggestions: GeocodingSuggestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReverseGeocodeResponse {
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
categories: () => []
|
categories: () => []
|
||||||
@@ -32,12 +46,23 @@ const media = ref<File[]>([])
|
|||||||
const errors = ref<Record<string, string>>({})
|
const errors = ref<Record<string, string>>({})
|
||||||
const successMessage = ref(false)
|
const successMessage = ref(false)
|
||||||
const isGettingLocation = ref(false)
|
const isGettingLocation = ref(false)
|
||||||
|
const addressSuggestions = ref<GeocodingSuggestion[]>([])
|
||||||
|
const isSearchingAddress = ref(false)
|
||||||
|
const isResolvingAddress = ref(false)
|
||||||
|
const addressSearchError = ref('')
|
||||||
|
const showAddressSuggestions = ref(false)
|
||||||
|
|
||||||
// Map variables
|
// Map variables
|
||||||
const mapContainer = ref<HTMLElement | null>(null)
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
let leaflet: typeof import('leaflet') | null = null
|
let leaflet: typeof import('leaflet') | null = null
|
||||||
let map: LeafletMap | null = null
|
let map: LeafletMap | null = null
|
||||||
let marker: LeafletMarker | null = null
|
let marker: LeafletMarker | null = null
|
||||||
|
let addressSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let manualLocationSyncTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let addressSearchRequestId = 0
|
||||||
|
let reverseGeocodeRequestId = 0
|
||||||
|
let lastSyncedLocationKey = ''
|
||||||
|
let skipNextAddressSearch = false
|
||||||
|
|
||||||
const loadLeaflet = async () => {
|
const loadLeaflet = async () => {
|
||||||
if (leaflet) return leaflet
|
if (leaflet) return leaflet
|
||||||
@@ -60,6 +85,175 @@ const loadLeaflet = async () => {
|
|||||||
return leaflet
|
return leaflet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toLocationKey = (lat: number, lng: number) => `${lat.toFixed(6)}:${lng.toFixed(6)}`
|
||||||
|
|
||||||
|
const clearAddressSuggestions = () => {
|
||||||
|
addressSuggestions.value = []
|
||||||
|
showAddressSuggestions.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelPendingAddressSearch = () => {
|
||||||
|
addressSearchRequestId += 1
|
||||||
|
isSearchingAddress.value = false
|
||||||
|
|
||||||
|
if (addressSearchTimeout) {
|
||||||
|
clearTimeout(addressSearchTimeout)
|
||||||
|
addressSearchTimeout = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelPendingReverseGeocode = () => {
|
||||||
|
reverseGeocodeRequestId += 1
|
||||||
|
isResolvingAddress.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAddressValue = (address: string) => {
|
||||||
|
cancelPendingAddressSearch()
|
||||||
|
skipNextAddressSearch = true
|
||||||
|
form.value.address = address
|
||||||
|
|
||||||
|
if (errors.value.address && address.trim()) {
|
||||||
|
errors.value.address = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAddressSuggestions = async (query: string) => {
|
||||||
|
const requestId = ++addressSearchRequestId
|
||||||
|
isSearchingAddress.value = true
|
||||||
|
addressSearchError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<SearchAddressesResponse>('/api/geocoding/search', {
|
||||||
|
query: { q: query }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== addressSearchRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addressSuggestions.value = response.suggestions
|
||||||
|
showAddressSuggestions.value = response.suggestions.length > 0
|
||||||
|
|
||||||
|
if (!response.suggestions.length) {
|
||||||
|
addressSearchError.value = 'Nessun indirizzo trovato nel Comune di Bari.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId !== addressSearchRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAddressSuggestions()
|
||||||
|
addressSearchError.value = 'Ricerca indirizzi non disponibile al momento.'
|
||||||
|
} finally {
|
||||||
|
if (requestId === addressSearchRequestId) {
|
||||||
|
isSearchingAddress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAddressFromCoordinates = async (lat: number, lng: number, fallbackAddress = '') => {
|
||||||
|
const requestId = ++reverseGeocodeRequestId
|
||||||
|
isResolvingAddress.value = true
|
||||||
|
addressSearchError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<ReverseGeocodeResponse>('/api/geocoding/reverse', {
|
||||||
|
query: { lat, lng }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== reverseGeocodeRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedAddress = response.address?.trim() || fallbackAddress.trim()
|
||||||
|
|
||||||
|
if (resolvedAddress) {
|
||||||
|
setAddressValue(resolvedAddress)
|
||||||
|
clearAddressSuggestions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.trim()) {
|
||||||
|
addressSearchError.value = 'Indirizzo non trovato per coordinate selezionate.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId !== reverseGeocodeRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackAddress.trim()) {
|
||||||
|
setAddressValue(fallbackAddress.trim())
|
||||||
|
clearAddressSuggestions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.trim()) {
|
||||||
|
addressSearchError.value = 'Impossibile recuperare indirizzo da coordinate.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === reverseGeocodeRequestId) {
|
||||||
|
isResolvingAddress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddressFocus = () => {
|
||||||
|
if (addressSuggestions.value.length > 0) {
|
||||||
|
showAddressSuggestions.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddressBlur = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
showAddressSuggestions.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLocation = async (
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
options: {
|
||||||
|
zoomTo?: boolean
|
||||||
|
syncAddress?: boolean
|
||||||
|
fallbackAddress?: string
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
const { zoomTo = false, syncAddress = true, fallbackAddress = '' } = options
|
||||||
|
|
||||||
|
if (!isWithinBariBounds(lat, lng)) {
|
||||||
|
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundedLat = Number(lat.toFixed(6))
|
||||||
|
const roundedLng = Number(lng.toFixed(6))
|
||||||
|
|
||||||
|
lastSyncedLocationKey = toLocationKey(roundedLat, roundedLng)
|
||||||
|
form.value.latitude = roundedLat
|
||||||
|
form.value.longitude = roundedLng
|
||||||
|
|
||||||
|
if (map && leaflet) {
|
||||||
|
if (!marker) {
|
||||||
|
marker = leaflet.marker([roundedLat, roundedLng]).addTo(map)
|
||||||
|
} else {
|
||||||
|
marker.setLatLng([roundedLat, roundedLng])
|
||||||
|
}
|
||||||
|
if (zoomTo) {
|
||||||
|
map.setView([roundedLat, roundedLng], 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.value.location) {
|
||||||
|
errors.value.location = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncAddress) {
|
||||||
|
await resolveAddressFromCoordinates(roundedLat, roundedLng, fallbackAddress)
|
||||||
|
} else if (fallbackAddress.trim()) {
|
||||||
|
setAddressValue(fallbackAddress.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initMap = async () => {
|
const initMap = async () => {
|
||||||
if (!import.meta.client || !mapContainer.value || map) return
|
if (!import.meta.client || !mapContainer.value || map) return
|
||||||
|
|
||||||
@@ -88,38 +282,31 @@ const initMap = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLocation(e.latlng.lat, e.latlng.lng)
|
void updateLocation(e.latlng.lat, e.latlng.lng)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Se c'è già una posizione, imposta il marker
|
// Se c'è già una posizione, imposta il marker
|
||||||
if (form.value.latitude && form.value.longitude) {
|
if (form.value.latitude && form.value.longitude) {
|
||||||
updateLocation(form.value.latitude, form.value.longitude, true)
|
void updateLocation(form.value.latitude, form.value.longitude, { zoomTo: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLocation = (lat: number, lng: number, zoomTo = false) => {
|
const selectAddressSuggestion = async (suggestion: GeocodingSuggestion) => {
|
||||||
if (!isWithinBariBounds(lat, lng)) {
|
addressSearchError.value = ''
|
||||||
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
|
clearAddressSuggestions()
|
||||||
|
setAddressValue(suggestion.label)
|
||||||
|
await updateLocation(suggestion.latitude, suggestion.longitude, {
|
||||||
|
zoomTo: true,
|
||||||
|
syncAddress: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddressEnter = async () => {
|
||||||
|
if (addressSuggestions.value.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
form.value.latitude = Number(lat.toFixed(6))
|
await selectAddressSuggestion(addressSuggestions.value[0])
|
||||||
form.value.longitude = Number(lng.toFixed(6))
|
|
||||||
|
|
||||||
if (map && leaflet) {
|
|
||||||
if (!marker) {
|
|
||||||
marker = leaflet.marker([lat, lng]).addTo(map)
|
|
||||||
} else {
|
|
||||||
marker.setLatLng([lat, lng])
|
|
||||||
}
|
|
||||||
if (zoomTo) {
|
|
||||||
map.setView([lat, lng], 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.value.location) {
|
|
||||||
errors.value.location = ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -127,6 +314,14 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
cancelPendingAddressSearch()
|
||||||
|
cancelPendingReverseGeocode()
|
||||||
|
|
||||||
|
if (manualLocationSyncTimeout) {
|
||||||
|
clearTimeout(manualLocationSyncTimeout)
|
||||||
|
manualLocationSyncTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
if (map) {
|
if (map) {
|
||||||
map.remove()
|
map.remove()
|
||||||
map = null
|
map = null
|
||||||
@@ -168,7 +363,7 @@ const getGeolocation = async () => {
|
|||||||
navigator.geolocation.getCurrentPosition(resolve, reject)
|
navigator.geolocation.getCurrentPosition(resolve, reject)
|
||||||
})
|
})
|
||||||
|
|
||||||
updateLocation(position.coords.latitude, position.coords.longitude, true)
|
await updateLocation(position.coords.latitude, position.coords.longitude, { zoomTo: true })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.'
|
errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -200,6 +395,12 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
media.value = []
|
media.value = []
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
|
addressSearchError.value = ''
|
||||||
|
clearAddressSuggestions()
|
||||||
|
cancelPendingAddressSearch()
|
||||||
|
cancelPendingReverseGeocode()
|
||||||
|
lastSyncedLocationKey = ''
|
||||||
|
skipNextAddressSearch = false
|
||||||
|
|
||||||
if (marker && map) {
|
if (marker && map) {
|
||||||
map.removeLayer(marker)
|
map.removeLayer(marker)
|
||||||
@@ -214,6 +415,35 @@ watch(() => form.value.category_id, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => form.value.address, (newAddress) => {
|
||||||
|
if (skipNextAddressSearch) {
|
||||||
|
skipNextAddressSearch = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPendingReverseGeocode()
|
||||||
|
|
||||||
|
if (errors.value.address && newAddress.trim()) {
|
||||||
|
errors.value.address = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addressSearchTimeout) {
|
||||||
|
clearTimeout(addressSearchTimeout)
|
||||||
|
addressSearchTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAddress.trim().length < 3) {
|
||||||
|
isSearchingAddress.value = false
|
||||||
|
addressSearchError.value = ''
|
||||||
|
clearAddressSuggestions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addressSearchTimeout = setTimeout(() => {
|
||||||
|
void fetchAddressSuggestions(newAddress.trim())
|
||||||
|
}, 350)
|
||||||
|
})
|
||||||
|
|
||||||
// Sincronizza input manuali con la mappa
|
// Sincronizza input manuali con la mappa
|
||||||
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
|
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
|
||||||
if (newLat && newLng && map && leaflet) {
|
if (newLat && newLng && map && leaflet) {
|
||||||
@@ -226,6 +456,23 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]
|
|||||||
map.setView([newLat, newLng], 16)
|
map.setView([newLat, newLng], 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!newLat || !newLng) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationKey = toLocationKey(newLat, newLng)
|
||||||
|
if (locationKey === lastSyncedLocationKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manualLocationSyncTimeout) {
|
||||||
|
clearTimeout(manualLocationSyncTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
manualLocationSyncTimeout = setTimeout(() => {
|
||||||
|
void updateLocation(newLat, newLng, { zoomTo: true })
|
||||||
|
}, 500)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -320,15 +567,44 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]
|
|||||||
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
|
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
|
||||||
Indirizzo *
|
Indirizzo *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="position-relative">
|
||||||
id="address"
|
<input
|
||||||
v-model="form.address"
|
id="address"
|
||||||
type="text"
|
v-model="form.address"
|
||||||
class="form-control"
|
type="text"
|
||||||
:class="{ 'is-invalid': errors.address }"
|
class="form-control"
|
||||||
placeholder="Es. Via Sparano 12, Bari"
|
:class="{ 'is-invalid': errors.address }"
|
||||||
required
|
placeholder="Es. Via Sparano 12, Bari"
|
||||||
/>
|
autocomplete="street-address"
|
||||||
|
required
|
||||||
|
@focus="handleAddressFocus"
|
||||||
|
@blur="handleAddressBlur"
|
||||||
|
@keydown.enter.prevent="handleAddressEnter"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showAddressSuggestions && addressSuggestions.length > 0"
|
||||||
|
class="list-group position-absolute w-100 shadow-sm rounded-3 overflow-hidden address-suggestions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="suggestion in addressSuggestions"
|
||||||
|
:key="`${suggestion.latitude}-${suggestion.longitude}-${suggestion.label}`"
|
||||||
|
type="button"
|
||||||
|
class="list-group-item list-group-item-action text-start"
|
||||||
|
@mousedown.prevent="selectAddressSuggestion(suggestion)"
|
||||||
|
>
|
||||||
|
{{ suggestion.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isSearchingAddress" class="form-text">
|
||||||
|
Ricerca indirizzi in corso...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isResolvingAddress" class="form-text">
|
||||||
|
Recupero indirizzo da coordinate...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="addressSearchError" class="form-text text-muted">
|
||||||
|
{{ addressSearchError }}
|
||||||
|
</div>
|
||||||
<div v-if="errors.address" class="invalid-feedback">
|
<div v-if="errors.address" class="invalid-feedback">
|
||||||
{{ errors.address }}
|
{{ errors.address }}
|
||||||
</div>
|
</div>
|
||||||
@@ -399,6 +675,14 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]
|
|||||||
z-index: 1; /* Fix for Bootstrap elements overlapping map */
|
z-index: 1; /* Fix for Bootstrap elements overlapping map */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-suggestions {
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -411,4 +695,3 @@ textarea {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createError, getQuery } from 'h3'
|
||||||
|
import { reverseGeocode } from '../../utils/geocoding'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const latitude = typeof query.lat === 'string' ? Number.parseFloat(query.lat) : Number.NaN
|
||||||
|
const longitude = typeof query.lng === 'string' ? Number.parseFloat(query.lng) : Number.NaN
|
||||||
|
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Invalid coordinates'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = await reverseGeocode(latitude, longitude)
|
||||||
|
|
||||||
|
return { address }
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createError, getQuery } from 'h3'
|
||||||
|
import { searchAddresses } from '../../utils/geocoding'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const rawText = typeof query.q === 'string' ? query.q.trim() : ''
|
||||||
|
const rawLimit = typeof query.limit === 'string' ? Number.parseInt(query.limit, 10) : 5
|
||||||
|
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 8) : 5
|
||||||
|
|
||||||
|
if (rawText.length < 3) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Query too short'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = await searchAddresses(rawText, limit)
|
||||||
|
|
||||||
|
return { suggestions }
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
interface NominatimAddress {
|
||||||
|
road?: string
|
||||||
|
house_number?: string
|
||||||
|
pedestrian?: string
|
||||||
|
footway?: string
|
||||||
|
path?: string
|
||||||
|
cycleway?: string
|
||||||
|
suburb?: string
|
||||||
|
neighbourhood?: string
|
||||||
|
city?: string
|
||||||
|
town?: string
|
||||||
|
village?: string
|
||||||
|
municipality?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NominatimResult {
|
||||||
|
lat: string
|
||||||
|
lon: string
|
||||||
|
display_name: string
|
||||||
|
address?: NominatimAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeocodingSuggestion {
|
||||||
|
label: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOMINATIM_BASE_URL = 'https://nominatim.openstreetmap.org'
|
||||||
|
const BARI_BOUNDS = {
|
||||||
|
north: 41.1696,
|
||||||
|
south: 41.0718,
|
||||||
|
east: 16.9521,
|
||||||
|
west: 16.7564
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOMINATIM_HEADERS = {
|
||||||
|
'User-Agent': 'Visione Frontend/1.0 (https://visione.studentindipendenti.it)',
|
||||||
|
'Referer': 'https://visione.studentindipendenti.it/'
|
||||||
|
}
|
||||||
|
|
||||||
|
const bariViewbox = `${BARI_BOUNDS.west},${BARI_BOUNDS.north},${BARI_BOUNDS.east},${BARI_BOUNDS.south}`
|
||||||
|
|
||||||
|
const isWithinBariBounds = (latitude: number, longitude: number) =>
|
||||||
|
latitude >= BARI_BOUNDS.south &&
|
||||||
|
latitude <= BARI_BOUNDS.north &&
|
||||||
|
longitude >= BARI_BOUNDS.west &&
|
||||||
|
longitude <= BARI_BOUNDS.east
|
||||||
|
|
||||||
|
const compactAddress = (displayName: string, address?: NominatimAddress) => {
|
||||||
|
if (!address) {
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
const streetName = address.road || address.pedestrian || address.footway || address.path || address.cycleway
|
||||||
|
const street = [streetName, address.house_number].filter(Boolean).join(' ')
|
||||||
|
const locality = address.city || address.town || address.village || address.municipality || 'Bari'
|
||||||
|
const area = address.suburb || address.neighbourhood
|
||||||
|
const formatted = [street, locality, area]
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((part, index, all) => all.indexOf(part) === index)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return formatted || displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSuggestion = (result: NominatimResult): GeocodingSuggestion | null => {
|
||||||
|
const latitude = Number.parseFloat(result.lat)
|
||||||
|
const longitude = Number.parseFloat(result.lon)
|
||||||
|
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: compactAddress(result.display_name, result.address),
|
||||||
|
latitude,
|
||||||
|
longitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchAddresses = async (query: string, limit = 5) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
format: 'jsonv2',
|
||||||
|
addressdetails: '1',
|
||||||
|
'accept-language': 'it',
|
||||||
|
countrycodes: 'it',
|
||||||
|
bounded: '1',
|
||||||
|
viewbox: bariViewbox,
|
||||||
|
limit: String(limit)
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await $fetch<NominatimResult[]>(`${NOMINATIM_BASE_URL}/search?${params.toString()}`, {
|
||||||
|
headers: NOMINATIM_HEADERS
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
.map(toSuggestion)
|
||||||
|
.filter((result): result is GeocodingSuggestion => Boolean(result))
|
||||||
|
.filter((result) => isWithinBariBounds(result.latitude, result.longitude))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reverseGeocode = async (latitude: number, longitude: number) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
lat: String(latitude),
|
||||||
|
lon: String(longitude),
|
||||||
|
zoom: '18',
|
||||||
|
format: 'jsonv2',
|
||||||
|
addressdetails: '1',
|
||||||
|
'accept-language': 'it'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await $fetch<NominatimResult | null>(`${NOMINATIM_BASE_URL}/reverse?${params.toString()}`, {
|
||||||
|
headers: NOMINATIM_HEADERS
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return compactAddress(result.display_name, result.address)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user