mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-07-16 00:37:21 +02:00
2d3ca459ea
* 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>
272 lines
7.4 KiB
Vue
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>
|