diff --git a/app/components/ReportForm.vue b/app/components/ReportForm.vue index ec546e8..c03254f 100644 --- a/app/components/ReportForm.vue +++ b/app/components/ReportForm.vue @@ -13,6 +13,20 @@ interface Emits { (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(), { isLoading: false, categories: () => [] @@ -32,12 +46,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 +85,175 @@ 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 { + const response = await $fetch('/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('/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 () => { if (!import.meta.client || !mapContainer.value || map) return @@ -88,38 +282,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 +314,14 @@ onMounted(() => { }) onUnmounted(() => { + cancelPendingAddressSearch() + cancelPendingReverseGeocode() + + if (manualLocationSyncTimeout) { + clearTimeout(manualLocationSyncTimeout) + manualLocationSyncTimeout = null + } + if (map) { map.remove() map = null @@ -168,7 +363,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 +395,12 @@ const resetForm = () => { } media.value = [] errors.value = {} + addressSearchError.value = '' + clearAddressSuggestions() + cancelPendingAddressSearch() + cancelPendingReverseGeocode() + lastSyncedLocationKey = '' + skipNextAddressSearch = false if (marker && map) { 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 watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => { if (newLat && newLng && map && leaflet) { @@ -226,6 +456,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 +567,44 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng] Indirizzo * - +
+ +
+ +
+
+
+ Ricerca indirizzi in corso... +
+
+ Recupero indirizzo da coordinate... +
+
+ {{ addressSearchError }} +
{{ errors.address }}
@@ -399,6 +675,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; @@ -411,4 +695,3 @@ textarea { } - diff --git a/server/api/geocoding/reverse.get.ts b/server/api/geocoding/reverse.get.ts new file mode 100644 index 0000000..cdd1a19 --- /dev/null +++ b/server/api/geocoding/reverse.get.ts @@ -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 } +}) diff --git a/server/api/geocoding/search.get.ts b/server/api/geocoding/search.get.ts new file mode 100644 index 0000000..231d2b9 --- /dev/null +++ b/server/api/geocoding/search.get.ts @@ -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 } +}) diff --git a/server/utils/geocoding.ts b/server/utils/geocoding.ts new file mode 100644 index 0000000..29122d2 --- /dev/null +++ b/server/utils/geocoding.ts @@ -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(`${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(`${NOMINATIM_BASE_URL}/reverse?${params.toString()}`, { + headers: NOMINATIM_HEADERS + }) + + if (!result) { + return '' + } + + return compactAddress(result.display_name, result.address) +}