diff --git a/README.md b/README.md
index dcd69b0..2fd39e8 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,10 @@ See photos of common ALPRs and learn about their capabilities.
### Running Backend
+#### Prerequisites
+* JDK 11
+* SBT
+
1. `cd shotgun`
2. `sbt run`
diff --git a/shotgun/src/main/scala/services/NominatimClient.scala b/shotgun/src/main/scala/services/NominatimClient.scala
index 48ecf99..2ec6fcf 100644
--- a/shotgun/src/main/scala/services/NominatimClient.scala
+++ b/shotgun/src/main/scala/services/NominatimClient.scala
@@ -31,7 +31,7 @@ class NominatimClient(implicit val system: ActorSystem, implicit val executionCo
case _ =>
println(s"Cache miss for $query")
val request = HttpRequest(
- uri = s"$baseUrl?q=$query&format=json",
+ uri = s"$baseUrl?q=$query&polygon_geojson=1&format=json",
headers = List(headers.`User-Agent`("DeFlock/1.0"))
)
diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue
index 2730ed8..cf3c4c9 100644
--- a/webapp/src/components/LeafletMap.vue
+++ b/webapp/src/components/LeafletMap.vue
@@ -8,23 +8,47 @@
-
-
-
-
- mdi-chart-bubble
- Grouping
-
-
-
-
+
+
+
+
+
+
+
+ mdi-chart-bubble
+ Grouping
+
+
+
+
+
+
+
+
+
+
+
+ mdi-map-outline
+ City Boundaries
+
+
+
+
+
+
@@ -84,6 +108,9 @@ const clusteringEnabled = ref(true);
const currentZoom = ref(0);
const zoomWarningDismissed = ref(false);
+// City Boundaries Control
+const cityBoundariesVisible = ref(true);
+
// Computed property to determine if clustering should be active based on zoom and user preference
const shouldCluster = computed(() => {
// Force clustering ON when zoomed out (below zoom 12) regardless of user preference
@@ -112,6 +139,10 @@ const props = defineProps({
type: Array as PropType
,
default: () => [],
},
+ geojson: {
+ type : Object as PropType,
+ default: null,
+ },
currentLocation: {
type: Object as PropType<[number, number] | null>,
default: null,
@@ -316,6 +347,10 @@ function initializeMap() {
map.addLayer(clusterLayer);
registerMapEvents();
+ if (props.geojson) {
+ updateGeoJson(props.geojson);
+ }
+
if (props.alprs.length) {
updateMarkers(props.alprs);
} else {
@@ -347,6 +382,27 @@ function updateMarkers(newAlprs: ALPR[]): void {
clusterLayer.addLayer(circlesLayer);
}
+function updateGeoJson(newGeoJson: GeoJSON.GeoJsonObject | null): void {
+ map.eachLayer((layer) => {
+ if (layer instanceof L.GeoJSON) {
+ map.removeLayer(layer);
+ }
+ });
+
+ if (newGeoJson && cityBoundariesVisible.value) {
+ const geoJsonLayer = L.geoJSON(newGeoJson, {
+ style: {
+ color: '#3388ff',
+ weight: 2,
+ opacity: 1,
+ fillOpacity: 0.2,
+ },
+ interactive: false, // Make unclickable
+ });
+ geoJsonLayer.addTo(map);
+ }
+}
+
function updateCurrentLocation(): void {
currentLocationLayer.clearLayers();
@@ -436,6 +492,16 @@ onMounted(() => {
updateMarkers(newAlprs);
}, { deep: true });
+ watch(() => props.geojson, (newGeoJson) => {
+ updateGeoJson(newGeoJson);
+ cityBoundariesVisible.value = true;
+ }, { deep: true });
+
+ // Watch for city boundaries visibility changes
+ watch(() => cityBoundariesVisible.value, () => {
+ updateGeoJson(props.geojson);
+ });
+
watch(() => props.currentLocation, () => {
updateCurrentLocation();
});
diff --git a/webapp/src/views/Map.vue b/webapp/src/views/Map.vue
index aa5f3db..80b13e3 100644
--- a/webapp/src/views/Map.vue
+++ b/webapp/src/views/Map.vue
@@ -8,6 +8,7 @@
:current-location="currentLocation"
@update:bounds="updateBounds"
:alprs
+ :geojson
>
@@ -23,11 +24,11 @@
variant="solo"
clearable
hide-details
- v-model="searchQuery"
+ v-model="searchInput"
type="search"
>
-
+
Gomdi-chevron-right
@@ -69,7 +70,9 @@ const zoom: Ref = ref(DEFAULT_ZOOM);
const center: Ref = ref(null);
const bounds: Ref = ref(null);
const searchField: Ref = ref(null);
-const searchQuery: Ref = ref('');
+const searchInput: Ref = ref(''); // For the text input field
+const searchQuery: Ref = ref(''); // For URL and boundaries (persistent)
+const geojson: Ref = ref(null);
const tilesStore = useTilesStore();
const { fetchVisibleTiles } = tilesStore;
@@ -92,19 +95,48 @@ function handleKeyUp(event: KeyboardEvent) {
function onSearch() {
searchField.value?.blur();
- if (!searchQuery.value) {
+ if (!searchInput.value) {
return;
}
- geocodeQuery(searchQuery.value, center.value)
+ geocodeQuery(searchInput.value, center.value)
.then((result: any) => {
if (!result) {
alert('No results found');
return;
}
const { lat, lon: lng } = result;
- center.value = { lat, lng };
- zoom.value = DEFAULT_ZOOM;
- searchQuery.value = '';
+ center.value = { lat: parseFloat(lat), lng: parseFloat(lng) };
+
+ // If we have GeoJSON with bounds, zoom to fit the bounds
+ if (result.geojson) {
+ geojson.value = result.geojson;
+
+ // Calculate bounds from GeoJSON to zoom to fit
+ const geoJsonLayer = L.geoJSON(result.geojson);
+ const bounds = geoJsonLayer.getBounds();
+
+ setTimeout(() => {
+ const latDiff = bounds.getNorth() - bounds.getSouth();
+ const lngDiff = bounds.getEast() - bounds.getWest();
+ const maxDiff = Math.max(latDiff, lngDiff);
+
+ // Rough zoom calculation based on bounds size
+ if (maxDiff > 10) zoom.value = 6;
+ else if (maxDiff > 5) zoom.value = 7;
+ else if (maxDiff > 2) zoom.value = 8;
+ else if (maxDiff > 1) zoom.value = 9;
+ else if (maxDiff > 0.5) zoom.value = 10;
+ else if (maxDiff > 0.2) zoom.value = 11;
+ else zoom.value = DEFAULT_ZOOM;
+ }, 100);
+ } else {
+ // No bounds, just use default zoom
+ zoom.value = DEFAULT_ZOOM;
+ }
+
+ searchQuery.value = searchInput.value; // Store the successful search query
+ updateURL();
+ searchInput.value = ''; // Clear the input field
});
}
@@ -114,6 +146,7 @@ function goToUserLocation() {
center.value = cl;
setTimeout(() => {
zoom.value = DEFAULT_ZOOM;
+ updateURL();
}, 10);
})
.catch(error => {
@@ -139,9 +172,14 @@ function updateURL() {
if (!center.value) {
return;
}
-
+
const currentRoute = router.currentRoute.value;
- const newHash = `#map=${zoom.value}/${center.value.lat.toFixed(6)}/${center.value.lng.toFixed(6)}`;
+ // URL encode searchQuery.value
+ const encodedSearchValue = searchQuery.value ? encodeURIComponent(searchQuery.value) : null;
+
+ const baseHash = `#map=${zoom.value}/${center.value.lat.toFixed(6)}/${center.value.lng.toFixed(6)}`;
+ const maybeSuffix = encodedSearchValue ? `/${encodedSearchValue}` : '';
+ const newHash = baseHash + maybeSuffix;
router.replace({
path: currentRoute.path,
@@ -160,16 +198,22 @@ function updateMarkers() {
}
onMounted(() => {
+ // Expected hash format like #map=///
const hash = router.currentRoute.value.hash;
if (hash) {
const parts = hash.split('/');
- if (parts.length === 3 && parts[0].startsWith('#map')) {
+ if (parts.length >= 3 && parts[0].startsWith('#map')) {
const zoomLevelString = parts[0].replace('#map=', '');
zoom.value = parseInt(zoomLevelString, 10);
center.value = {
lat: parseFloat(parts[1]),
lng: parseFloat(parts[2]),
};
+ if (parts.length >= 4 && parts[3]) {
+ searchQuery.value = decodeURIComponent(parts[3]);
+ searchInput.value = searchQuery.value; // Populate input field with URL search query
+ onSearch()
+ }
}
} else {
// show US map by default