implement address search and reverse geocoding APIs #5

Merged
reloia merged 3 commits from address-autocomplete into master 2026-06-15 13:27:00 +00:00
3 changed files with 412 additions and 57 deletions
+307 -34
View File
@@ -3,6 +3,7 @@ import type { Category, CreateReportRequest } from '~/types/api'
import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet' import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet'
import { onMounted, onUnmounted, ref, watch } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari' import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
import { reverseGeocode, searchAddresses } from '~/utils/geocoding'
interface Props { interface Props {
isLoading?: boolean isLoading?: boolean
@@ -13,6 +14,12 @@ interface Emits {
(e: 'submit', report: CreateReportRequest, media: File[]): void (e: 'submit', report: CreateReportRequest, media: File[]): void
} }
interface GeocodingSuggestion {
label: string
latitude: number
longitude: number
}
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isLoading: false, isLoading: false,
categories: () => [] categories: () => []
@@ -32,12 +39,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 +78,173 @@ 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 {
if (requestId !== addressSearchRequestId) {
return
}
const suggestions = await searchAddresses(query)
if (requestId !== addressSearchRequestId) {
return
}
addressSuggestions.value = suggestions
showAddressSuggestions.value = suggestions.length > 0
if (!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 {
if (requestId !== reverseGeocodeRequestId) {
return
}
const resolvedAddress = (await reverseGeocode(lat, lng))?.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 +273,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 +305,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 +354,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 +386,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 +406,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 +447,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 +558,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 +666,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;
@@ -410,5 +685,3 @@ textarea {
align-items: center; align-items: center;
} }
</style> </style>
+103
View File
@@ -0,0 +1,103 @@
import { BARI_BOUNDS, isWithinBariBounds } from '~/utils/bari'
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 bariViewbox = `${BARI_BOUNDS.west},${BARI_BOUNDS.north},${BARI_BOUNDS.east},${BARI_BOUNDS.south}`
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()}`)
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()}`)
if (!result) {
return ''
}
return compactAddress(result.display_name, result.address)
}
+2 -23
View File
@@ -8,6 +8,8 @@ export default defineNuxtConfig({
'@nuxtjs/sitemap', '@nuxtjs/sitemap',
'@nuxtjs/robots' '@nuxtjs/robots'
], ],
// TODO: riattivare prima di pubblicare il sito
ssr: false,
runtimeConfig: { runtimeConfig: {
public: { public: {
@@ -35,29 +37,6 @@ export default defineNuxtConfig({
] ]
}, },
nitro: {
devProxy: {
'/api/v1': {
target: 'https://bk.visione.studentindipendenti.it/api/v1',
changeOrigin: true,
headers: {
host: 'bk.visione.studentindipendenti.it',
origin: 'https://visione.studentindipendenti.it',
referer: 'https://visione.studentindipendenti.it/'
}
},
'/uploads': {
target: 'https://bk.visione.studentindipendenti.it/uploads',
changeOrigin: true,
headers: {
host: 'bk.visione.studentindipendenti.it',
origin: 'https://visione.studentindipendenti.it',
referer: 'https://visione.studentindipendenti.it/'
}
}
}
},
app: { app: {
head: { head: {
link: [ link: [