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:
superbrom
2025-11-29 20:40:12 -05:00
committed by GitHub
parent f59a6ee819
commit 2d3ca459ea
4 changed files with 143 additions and 29 deletions

View File

@@ -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();
});

View File

@@ -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 => {
@@ -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=<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