Files
deflock/webapp/src/views/Map.vue
T
superbrom 2d3ca459ea 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>
2025-11-29 18:40:12 -07:00

272 lines
7.4 KiB
Vue

<template>
<NewVisitor />
<div class="map-container" @keyup="handleKeyUp">
<leaflet-map
v-if="center"
v-model:center="center"
v-model:zoom="zoom"
:current-location="currentLocation"
@update:bounds="updateBounds"
:alprs
:geojson
>
<!-- SEARCH -->
<template v-slot:topleft>
<form @submit.prevent="onSearch">
<v-text-field
:rounded="xs || undefined"
:density="xs ? 'compact' : 'default'"
class="map-search"
ref="searchField"
prepend-inner-icon="mdi-magnify"
placeholder="Search for a location"
single-line
variant="solo"
clearable
hide-details
v-model="searchInput"
type="search"
>
<template v-slot:append-inner>
<v-btn :disabled="!searchInput" variant="text" flat color="#0080BC" @click="onSearch">
Go<v-icon end>mdi-chevron-right</v-icon>
</v-btn>
</template>
</v-text-field>
</form>
</template>
<!-- CURRENT LOCATION -->
<template v-slot:bottomright>
<v-fab icon="mdi-crosshairs-gps" @click="goToUserLocation" />
</template>
</leaflet-map>
<div v-else class="loader">
<span class="mb-4 text-grey">Loading Map</span>
<v-progress-circular indeterminate color="primary" />
</div>
</div>
</template>
<script setup lang="ts">
import 'leaflet/dist/leaflet.css';
import { ref, onMounted, computed, watch } from 'vue';
import { useRouter } from 'vue-router'
import type { Ref } from 'vue';
import { BoundingBox } from '@/services/apiService';
import { geocodeQuery } from '@/services/apiService';
import { useDisplay, useTheme } from 'vuetify';
import { useGlobalStore } from '@/stores/global';
import { useTilesStore } from '@/stores/tiles';
import L from 'leaflet';
globalThis.L = L;
import 'leaflet/dist/leaflet.css'
import LeafletMap from '@/components/LeafletMap.vue';
import NewVisitor from '@/components/NewVisitor.vue';
const DEFAULT_ZOOM = 12;
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 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;
const alprs = computed(() => tilesStore.allNodes);
const router = useRouter();
const { xs } = useDisplay();
const globalStore = useGlobalStore();
const setCurrentLocation = globalStore.setCurrentLocation;
const currentLocation = computed(() => globalStore.currentLocation);
function handleKeyUp(event: KeyboardEvent) {
if (event.key === '/' && searchField.value.value !== document.activeElement) {
searchField.value.focus();
event.preventDefault();
}
}
function onSearch() {
searchField.value?.blur();
if (!searchInput.value) {
return;
}
geocodeQuery(searchInput.value, center.value)
.then((result: any) => {
if (!result) {
alert('No results found');
return;
}
const { lat, lon: lng } = result;
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
});
}
function goToUserLocation() {
setCurrentLocation()
.then((cl) => {
center.value = cl;
setTimeout(() => {
zoom.value = DEFAULT_ZOOM;
updateURL();
}, 10);
})
.catch(error => {
console.debug('Error getting user location.', error);
});
}
function updateBounds(newBounds: any) {
updateURL();
const newBoundingBox = new BoundingBox({
minLat: newBounds.getSouth(),
maxLat: newBounds.getNorth(),
minLng: newBounds.getWest(),
maxLng: newBounds.getEast(),
});
bounds.value = newBoundingBox;
updateMarkers();
}
function updateURL() {
if (!center.value) {
return;
}
const currentRoute = router.currentRoute.value;
// 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,
query: currentRoute.query,
hash: newHash,
});
}
function updateMarkers() {
// Fetch ALPRs in the current view
if (!bounds.value) {
return;
}
fetchVisibleTiles(bounds.value);
}
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')) {
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
zoom.value = 5;
center.value = { lat: 39.8283, lng: -98.5795 };
}
});
</script>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
opacity: 0;
}
.map-container {
width: 100%;
overflow: auto;
}
.map-search {
width: calc(100vw - 22px);
@media (min-width: 600px) {
max-width: 320px;
}
z-index: 1000;
}
.map-notif {
position: absolute;
text-align: center;
bottom: 50%;
left: 50%;
transform: translate(-50%, 50%);
z-index: 1000;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 4px;
padding: 20px;
}
.loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #333;
}
</style>