diff --git a/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala b/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala index 9b6f88e..d25791e 100644 --- a/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala +++ b/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala @@ -13,8 +13,9 @@ import pekko.http.scaladsl.server.Directives.{path, _} import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ import org.apache.pekko.http.scaladsl.server.RejectionHandler -import scala.concurrent.{Await, ExecutionContextExecutor, Future} -import scala.concurrent.duration._ +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import scala.concurrent.ExecutionContextExecutor import scala.io.StdIn object ShotgunServer { @@ -26,6 +27,7 @@ object ShotgunServer { val logging = Logging(system, getClass) val client = new services.OverpassClient() + val nominatim = new services.NominatimClient() // CORS val allowedOrigins = List( @@ -60,6 +62,16 @@ object ShotgunServer { } } }, + path("geocode") { + get { + parameters("query".as[String]) { query => + val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString) + onSuccess(nominatim.geocodePhrase(encodedQuery)) { json => + complete(json) + } + } + } + }, path("oauth2" / "callback") { get { parameters(Symbol("code").?) { (code) => diff --git a/shotgun/src/main/scala/services/NominatimClient.scala b/shotgun/src/main/scala/services/NominatimClient.scala new file mode 100644 index 0000000..585b26b --- /dev/null +++ b/shotgun/src/main/scala/services/NominatimClient.scala @@ -0,0 +1,32 @@ +package services + +import org.apache.pekko +import org.apache.pekko.actor.ActorSystem +import pekko.http.scaladsl.Http +import pekko.http.scaladsl.model._ +import pekko.http.scaladsl.unmarshalling.Unmarshal +import spray.json._ +import scala.concurrent.{ExecutionContextExecutor, Future} + +class NominatimClient(implicit val system: ActorSystem, implicit val executionContext: ExecutionContextExecutor) { + val baseUrl = "https://nominatim.openstreetmap.org/search" + + def geocodePhrase(query: String): Future[JsValue] = { + val request = HttpRequest( + uri = s"$baseUrl?q=$query&format=json", + headers = List(headers.`User-Agent`("DeFlock/1.0")) + ) + + Http().singleRequest(request).flatMap { response => + response.status match { + case StatusCodes.OK => + Unmarshal(response.entity).to[String].map { jsonString => + jsonString.parseJson + } + case _ => + response.discardEntityBytes() + Future.failed(new Exception(s"Failed to geocode phrase: ${response.status}")) + } + } + } +} diff --git a/webapp/src/services/apiService.ts b/webapp/src/services/apiService.ts index 097cec1..0561fa9 100644 --- a/webapp/src/services/apiService.ts +++ b/webapp/src/services/apiService.ts @@ -54,3 +54,51 @@ export const getALPRs = async (boundingBox: BoundingBox) => { const response = await apiService.get(`/alpr?${queryParams.toString()}`); return response.data; } + +export const geocodeQuery = async (query: string, currentLocation: any) => { + const encodedQuery = encodeURIComponent(query); + const results = (await apiService.get(`/geocode?query=${encodedQuery}`)).data; + + function findNearestResult(results: any, currentLocation: any) { + console.log(currentLocation, results); + let nearestResult = results[0]; + let nearestDistance = Number.MAX_VALUE; + for (const result of results) { + const distance = Math.sqrt( + Math.pow(result.lat - currentLocation.lat, 2) + + Math.pow(result.lon - currentLocation.lng, 2) + ); + if (distance < nearestDistance) { + nearestResult = result; + nearestDistance = distance; + } + } + return nearestResult; + } + + if (!results.length) return null; + + const cityStatePattern = /(.+),\s*(\w{2})/; + const postalCodePattern = /\d{5}/; + + if (cityStatePattern.test(query)) { + console.debug("cityStatePattern"); + const cityStateResults = results.filter((result: any) => + ["city", "town", "village", "hamlet", "suburb", "quarter", "neighbourhood", "borough"].includes(result.addresstype) + ); + if (cityStateResults.length) { + return findNearestResult(cityStateResults, currentLocation); + } + } + + if (postalCodePattern.test(query)) { + console.debug("postalCodePattern"); + const postalCodeResults = results.filter((result: any) => result.addresstype === "postcode"); + if (postalCodeResults.length) { + return findNearestResult(postalCodeResults, currentLocation); + } + } + + console.debug("defaultPattern"); + return findNearestResult(results, currentLocation); +} diff --git a/webapp/src/views/HomeView.vue b/webapp/src/views/HomeView.vue index c35919d..9152d42 100644 --- a/webapp/src/views/HomeView.vue +++ b/webapp/src/views/HomeView.vue @@ -15,11 +15,31 @@ @ready="mapLoaded" :options="{ zoomControl: false, attributionControl: false }" > + + + + + + /> import 'leaflet/dist/leaflet.css'; -import { LMap, LTileLayer, LMarker, LPopup, LControlZoom } from '@vue-leaflet/vue-leaflet'; +import { LMap, LTileLayer, LMarker, LPopup, LControlZoom, LControl } from '@vue-leaflet/vue-leaflet'; import { ref, onMounted, computed } from 'vue'; import { useRouter } from 'vue-router' import type { Ref } from 'vue'; import { BoundingBox } from '@/services/apiService'; -import { getALPRs } from '@/services/apiService'; +import { getALPRs, geocodeQuery } from '@/services/apiService'; const zoom: Ref = ref(13); const center: Ref = ref(null); const bounds: Ref = ref(null); +const searchField: Ref = ref(null); +const searchQuery: Ref = ref(''); const router = useRouter(); const canRefreshMarkers = computed(() => zoom.value >= 10); @@ -58,6 +80,28 @@ const canRefreshMarkers = computed(() => zoom.value >= 10); const alprsInView: Ref = ref([]); const bboxForLastRequest: Ref = ref(null); +function onSearch() { + if (searchField.value) { + console.log('Blurring search field'); + searchField.value?.blur(); + } + if (!searchQuery.value) { + return; + } + geocodeQuery(searchQuery.value, center.value) + .then((result: any) => { + if (!result) { + alert('No results found'); + return; + } + console.log('Geocode result:', result); + const { lat, lon: lng } = result; + center.value = { lat, lng }; + zoom.value = 13; + searchQuery.value = ''; + }); +} + function getUserLocation(): Promise<[number, number]> { return new Promise((resolve, reject) => { if (navigator.geolocation) { @@ -171,6 +215,15 @@ onMounted(() => { overflow: auto; } +.map-search { + /* position: absolute; + top: 16px; + left: 16px; */ + width: calc(100vw - 32px); + max-width: 400px; + z-index: 1000; +} + .map-notif { position: absolute; text-align: center;