diff --git a/app/components/ReportForm.vue b/app/components/ReportForm.vue index 82ee57b..650e689 100644 --- a/app/components/ReportForm.vue +++ b/app/components/ReportForm.vue @@ -3,6 +3,7 @@ import type { Category, CreateReportRequest } from '~/types/api' import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet' import { onMounted, onUnmounted, ref, watch } from 'vue' import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari' +import { reverseGeocode, searchAddresses } from '~/utils/geocoding' interface Props { isLoading?: boolean @@ -13,6 +14,12 @@ interface Emits { (e: 'submit', report: CreateReportRequest, media: File[]): void } +interface GeocodingSuggestion { + label: string + latitude: number + longitude: number +} + const props = withDefaults(defineProps(), { isLoading: false, categories: () => [] @@ -32,12 +39,23 @@ const media = ref([]) const errors = ref>({}) const successMessage = ref(false) const isGettingLocation = ref(false) +const addressSuggestions = ref([]) +const isSearchingAddress = ref(false) +const isResolvingAddress = ref(false) +const addressSearchError = ref('') +const showAddressSuggestions = ref(false) // Map variables const mapContainer = ref(null) let leaflet: typeof import('leaflet') | null = null let map: LeafletMap | null = null let marker: LeafletMarker | null = null +let addressSearchTimeout: ReturnType | null = null +let manualLocationSyncTimeout: ReturnType | null = null +let addressSearchRequestId = 0 +let reverseGeocodeRequestId = 0 +let lastSyncedLocationKey = '' +let skipNextAddressSearch = false const loadLeaflet = async () => { if (leaflet) return leaflet @@ -60,6 +78,173 @@ const loadLeaflet = async () => { 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 () => { if (!import.meta.client || !mapContainer.value || map) return @@ -88,38 +273,31 @@ const initMap = async () => { return } - updateLocation(e.latlng.lat, e.latlng.lng) + void updateLocation(e.latlng.lat, e.latlng.lng) }) // Se c'è già una posizione, imposta il marker 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) => { - if (!isWithinBariBounds(lat, lng)) { - errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.' +const selectAddressSuggestion = async (suggestion: GeocodingSuggestion) => { + addressSearchError.value = '' + clearAddressSuggestions() + setAddressValue(suggestion.label) + await updateLocation(suggestion.latitude, suggestion.longitude, { + zoomTo: true, + syncAddress: false + }) +} + +const handleAddressEnter = async () => { + if (addressSuggestions.value.length === 0) { return } - form.value.latitude = Number(lat.toFixed(6)) - 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 = '' - } + await selectAddressSuggestion(addressSuggestions.value[0]) } onMounted(() => { @@ -127,6 +305,14 @@ onMounted(() => { }) onUnmounted(() => { + cancelPendingAddressSearch() + cancelPendingReverseGeocode() + + if (manualLocationSyncTimeout) { + clearTimeout(manualLocationSyncTimeout) + manualLocationSyncTimeout = null + } + if (map) { map.remove() map = null @@ -168,7 +354,7 @@ const getGeolocation = async () => { 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) { errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.' } finally { @@ -200,6 +386,12 @@ const resetForm = () => { } media.value = [] errors.value = {} + addressSearchError.value = '' + clearAddressSuggestions() + cancelPendingAddressSearch() + cancelPendingReverseGeocode() + lastSyncedLocationKey = '' + skipNextAddressSearch = false if (marker && map) { 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 watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => { if (newLat && newLng && map && leaflet) { @@ -226,6 +447,23 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng] 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) }) @@ -320,15 +558,44 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng] Indirizzo * - +
+ +
+ +
+
+
+ Ricerca indirizzi in corso... +
+
+ Recupero indirizzo da coordinate... +
+
+ {{ addressSearchError }} +
{{ errors.address }}
@@ -399,6 +666,14 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng] 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 { font-family: inherit; resize: vertical; @@ -410,5 +685,3 @@ textarea { align-items: center; } - - diff --git a/app/utils/geocoding.ts b/app/utils/geocoding.ts new file mode 100644 index 0000000..b177977 --- /dev/null +++ b/app/utils/geocoding.ts @@ -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(`${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(`${NOMINATIM_BASE_URL}/reverse?${params.toString()}`) + + if (!result) { + return '' + } + + return compactAddress(result.display_name, result.address) +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 387a267..6ede25a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -8,6 +8,8 @@ export default defineNuxtConfig({ '@nuxtjs/sitemap', '@nuxtjs/robots' ], + // TODO: riattivare prima di pubblicare il sito + ssr: false, runtimeConfig: { public: {