mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-02-12 15:02:45 +00:00
Geo query polygons (#56)
* allow declustering, update council page (#48) * option to disable clustering * update wins, add videos, clean up council page * improve grouping toggle * some cleanup * add polygon from geo query * use leaflet geo json type * store geo shape in url * improve malformed url handling. update nominatim client request * update readme for java version * use the query text in the url instead of encoded json * fix url persistence, toggle boundaries * style changes * update prefs on new search --------- Co-authored-by: Will Freeman <hohosanta@me.com>
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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"))
|
||||
)
|
||||
|
||||
|
||||
@@ -8,23 +8,47 @@
|
||||
</div>
|
||||
|
||||
<div class="topright">
|
||||
<!-- Clustering Toggle Switch -->
|
||||
<v-card v-if="!isFullScreen" variant="elevated">
|
||||
<v-card-text class="py-0">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="small" class="mr-2">mdi-chart-bubble</v-icon>
|
||||
<span class="text-caption mr-2">Grouping</span>
|
||||
<v-switch
|
||||
v-model="clusteringEnabled"
|
||||
:disabled="currentZoom < 12"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mx-1"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<!-- Controls -->
|
||||
<div v-if="!isFullScreen" class="d-flex flex-column ga-2">
|
||||
<!-- Clustering Toggle Switch -->
|
||||
<v-card variant="elevated">
|
||||
<v-card-text class="py-0">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span>
|
||||
<v-icon size="small" class="mr-2">mdi-chart-bubble</v-icon>
|
||||
<span class="text-caption mr-2">Grouping</span>
|
||||
</span>
|
||||
<v-switch
|
||||
v-model="clusteringEnabled"
|
||||
:disabled="currentZoom < 12"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mx-1"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- City Boundaries Toggle Switch -->
|
||||
<v-card v-if="geojson" variant="elevated">
|
||||
<v-card-text class="py-0">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span>
|
||||
<v-icon size="small" class="mr-2">mdi-map-outline</v-icon>
|
||||
<span class="text-caption mr-2">City Boundaries</span>
|
||||
</span>
|
||||
<v-switch
|
||||
v-model="cityBoundariesVisible"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mx-1"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottomright">
|
||||
@@ -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<ALPR[]>,
|
||||
default: () => [],
|
||||
},
|
||||
geojson: {
|
||||
type : Object as PropType<GeoJSON.GeoJsonObject | null>,
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
:current-location="currentLocation"
|
||||
@update:bounds="updateBounds"
|
||||
:alprs
|
||||
:geojson
|
||||
>
|
||||
<!-- SEARCH -->
|
||||
<template v-slot:topleft>
|
||||
@@ -23,11 +24,11 @@
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
v-model="searchQuery"
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-btn :disabled="!searchQuery" variant="text" flat color="#0080BC" @click="onSearch">
|
||||
<v-btn :disabled="!searchInput" variant="text" flat color="#0080BC" @click="onSearch">
|
||||
Go<v-icon end>mdi-chevron-right</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -69,7 +70,9 @@ const zoom: Ref<number> = ref(DEFAULT_ZOOM);
|
||||
const center: Ref<any|null> = ref(null);
|
||||
const bounds: Ref<BoundingBox|null> = ref(null);
|
||||
const searchField: Ref<any|null> = ref(null);
|
||||
const searchQuery: Ref<string> = ref('');
|
||||
const searchInput: Ref<string> = ref(''); // For the text input field
|
||||
const searchQuery: Ref<string> = ref(''); // For URL and boundaries (persistent)
|
||||
const geojson: Ref<GeoJSON.GeoJsonObject | null> = 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 => {
|
||||
@@ -141,7 +174,12 @@ function updateURL() {
|
||||
}
|
||||
|
||||
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=<ZOOM_LEVEL:int>/<LATITUDE:float>/<LONGITUDE:float>/<QUERY:text>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user