Files
deflock/webapp/src/components/LeafletMap.vue
2026-01-02 12:08:27 -06:00

649 lines
18 KiB
Vue

<template>
<div id="map" :style="{
height: isFullScreen ? '100dvh' : 'calc(100dvh - 64px)',
marginTop: isFullScreen ? '0' : '64px',
}">
<div class="topleft">
<slot name="topleft"></slot>
</div>
<div class="topright">
<!-- 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">
<v-btn icon to="/report" style="color: unset">
<v-icon size="large">mdi-map-marker-plus</v-icon>
</v-btn>
<slot name="bottomright"></slot>
</div>
<!-- Status Bar for Zoom Warning -->
<v-slide-y-transition>
<div
v-if="showAutoDisabledStatus"
class="clustering-status-bar"
>
<v-icon size="small" class="mr-2">mdi-information</v-icon>
<span class="text-caption">
Camera grouping is on for performance at this zoom level.
</span>
<v-btn
size="x-small"
icon
variant="text"
color="white"
class="ml-2"
@click="dismissZoomWarning"
>
<v-icon size="small">mdi-close</v-icon>
</v-btn>
</div>
</v-slide-y-transition>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, h, createApp, watch, ref, type PropType, type Ref } from 'vue';
import L, { type LatLngTuple, type FeatureGroup, type MarkerClusterGroup, type Marker, type CircleMarker } from 'leaflet';
import type { ALPR } from '@/types';
import DFMapPopup from './DFMapPopup.vue';
import { createVuetify } from 'vuetify'
import { useRoute } from 'vue-router';
import { computed } from 'vue';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import { useTheme } from 'vuetify';
const MARKER_COLOR = 'rgb(63,84,243)';
const CLUSTER_DISABLE_ZOOM = 16; // Clustering disabled at zoom 16 and above
// Internal State Management
const markerMap = new Map<string, Marker | CircleMarker>();
const isInternalUpdate = ref(false);
const route = useRoute();
const isFullScreen = computed(() => route.query.fullscreen === 'true');
// Clustering Control
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
if (currentZoom.value < 12) {
return true;
}
// At higher zoom levels, respect user preference
return clusteringEnabled.value && currentZoom.value < CLUSTER_DISABLE_ZOOM;
});
// Show status when clustering is disabled by user but forced ON due to zoom
const showAutoDisabledStatus = computed(() => {
return !clusteringEnabled.value && currentZoom.value < 12 && !zoomWarningDismissed.value;
});
const props = defineProps({
center: {
type: Object as PropType<LatLngTuple>,
required: true,
},
zoom: {
type: Number,
required: true,
},
alprs: {
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,
},
});
const emit = defineEmits(['update:center', 'update:zoom', 'update:bounds']);
// Map instance and layers
let map: L.Map;
let circlesLayer: FeatureGroup;
let clusterLayer: MarkerClusterGroup;
let currentLocationLayer: FeatureGroup;
// Marker Creation Utilities
function createSVGMarkers(alpr: ALPR): string {
const orientationValues = (alpr.tags['camera:direction'] || alpr.tags.direction || '')
.split(';')
.map(val => parseDirectionValue(val.trim()));
const fovPath = `
<path class="someSVGpath" d="M215.248,221.461L99.696,43.732C144.935,16.031 198.536,0 256,0C313.464,0 367.065,16.031 412.304,43.732L296.752,221.461C287.138,209.593 272.448,202.001 256,202.001C239.552,202.001 224.862,209.593 215.248,221.461Z" style="fill:rgb(87,87,87);fill-opacity:0.46;"/>
<path class="someSVGpath" d="M215.248,221.461L99.696,43.732C144.935,16.031 198.536,0 256,0C313.464,0 367.065,16.031 412.304,43.732L296.752,221.461C287.138,209.593 272.448,202.001 256,202.001C239.552,202.001 224.862,209.593 215.248,221.461ZM217.92,200.242C228.694,192.652 241.831,188.195 256,188.195C270.169,188.195 283.306,192.652 294.08,200.242C294.08,200.242 392.803,48.4 392.803,48.4C352.363,26.364 305.694,13.806 256,13.806C206.306,13.806 159.637,26.364 119.197,48.4L217.92,200.242Z" style="fill:rgb(137,135,135);"/>
`;
const allDirectionsPath = orientationValues.map(degrees => `
<g style="transform:rotate(${degrees}deg); transform-origin: center;">
${fovPath}
</g>
`).join('');
return `<svg class="svgMarker" width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
${allDirectionsPath}
<g transform="matrix(0.906623,0,0,0.906623,23.9045,22.3271)">
<circle class="someSVGpath" cx="256" cy="256" r="57.821" style="fill:${MARKER_COLOR};fill-opacity:0.41;"/>
<path class="someSVGpath" d="M256,174.25C301.119,174.25 337.75,210.881 337.75,256C337.75,301.119 301.119,337.75 256,337.75C210.881,337.75 174.25,301.119 174.25,256C174.25,210.881 210.881,174.25 256,174.25ZM256,198.179C224.088,198.179 198.179,224.088 198.179,256C198.179,287.912 224.088,313.821 256,313.821C287.912,313.821 313.821,287.912 313.821,256C313.821,224.088 287.912,198.179 256,198.179Z" style="fill:${MARKER_COLOR};"/>
</g>
</svg>
`;
}
function parseDirectionValue(value: string): number {
if (!value) return 0;
// Check if it's a range (contains '-' but not at the start)
if (value.includes('-') && value.indexOf('-') > 0) {
const parts = value.split('-');
if (parts.length === 2) {
const start = parseDirectionSingle(parts[0].trim());
const end = parseDirectionSingle(parts[1].trim());
return calculateMidpointAngle(start, end);
}
}
// Single value
return parseDirectionSingle(value);
}
function parseDirectionSingle(value: string): number {
// Try parsing as number first
if (/^\d+(\.\d+)?$/.test(value)) {
return parseFloat(value);
}
// Try cardinal direction
return cardinalToDegrees(value);
}
function calculateMidpointAngle(start: number, end: number): number {
// Normalize angles to 0-360 range
start = ((start % 360) + 360) % 360;
end = ((end % 360) + 360) % 360;
// Calculate the difference and handle wrap-around
let diff = end - start;
if (diff < 0) {
diff += 360;
}
// If the difference is greater than 180, go the other way around
if (diff > 180) {
diff = diff - 360;
}
// Calculate midpoint
let midpoint = start + diff / 2;
// Normalize result to 0-360 range
return ((midpoint % 360) + 360) % 360;
}
const CARDINAL_DIRECTIONS: Record<string, number> = {
N: 0,
NNE: 22.5,
NE: 45,
ENE: 67.5,
E: 90,
ESE: 112.5,
SE: 135,
SSE: 157.5,
S: 180,
SSW: 202.5,
SW: 225,
WSW: 247.5,
W: 270,
WNW: 292.5,
NW: 315,
NNW: 337.5
};
function cardinalToDegrees(cardinal: string): number {
const upperCardinal = cardinal.toUpperCase();
return CARDINAL_DIRECTIONS[upperCardinal] ?? parseFloat(cardinal) ?? 0;
}
function createMarker(alpr: ALPR): Marker | CircleMarker {
if (hasPlottableOrientation(alpr.tags.direction || alpr.tags['camera:direction'])) {
const icon = L.divIcon({
className: 'leaflet-data-marker',
html: createSVGMarkers(alpr),
iconSize: [60, 60],
iconAnchor: [30, 30],
popupAnchor: [0, 0],
});
return L.marker([alpr.lat, alpr.lon], { icon });
}
return L.circleMarker([alpr.lat, alpr.lon], {
fill: true,
fillColor: MARKER_COLOR,
fillOpacity: 0.6,
stroke: true,
color: MARKER_COLOR,
opacity: 1,
radius: 8,
weight: 3,
});
}
const theme = useTheme();
function bindPopup(marker: L.CircleMarker | L.Marker, alpr: ALPR): L.CircleMarker | L.Marker {
marker.bindPopup('');
marker.on('popupopen', (e: any) => {
const popupContent = document.createElement('div');
createApp({
render() {
return h(DFMapPopup, {
alpr: {
id: alpr.id,
lat: alpr.lat,
lon: alpr.lon,
tags: alpr.tags,
type: alpr.type,
}
});
}
}).use(createVuetify({
theme: {
defaultTheme: theme.global.name.value,
},
})).mount(popupContent);
e.popup.setContent(popupContent);
});
return marker;
}
function hasPlottableOrientation(orientationDegrees: string) {
if (!orientationDegrees) return false;
// Split by semicolon to handle multiple values (e.g. '0;90')
const values = orientationDegrees.split(';');
return values.some(value => {
const trimmed = value.trim();
// Check if it's a range (contains '-' but not at the start)
if (trimmed.includes('-') && trimmed.indexOf('-') > 0) {
return true; // Ranges are plottable
}
// Check if it's a numeric value
if (/^\d+(\.\d+)?$/.test(trimmed)) {
return true;
}
// Check if it's a valid cardinal direction
return CARDINAL_DIRECTIONS.hasOwnProperty(trimmed.toUpperCase());
});
}
// Map State Management
function initializeMap() {
map = L.map('map', {
zoomControl: false,
maxZoom: 18, // max for OSM tiles
minZoom: 3, // don't overload the browser
}).setView(props.center, props.zoom);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
clusterLayer = L.markerClusterGroup({
chunkedLoading: true,
disableClusteringAtZoom: shouldCluster.value ? CLUSTER_DISABLE_ZOOM : 1,
removeOutsideVisibleBounds: true,
maxClusterRadius: 60,
spiderfyOnEveryZoom: false,
spiderfyOnMaxZoom: false,
});
circlesLayer = L.featureGroup();
currentLocationLayer = L.featureGroup();
// Initialize current zoom
currentZoom.value = props.zoom;
map.addLayer(clusterLayer);
registerMapEvents();
if (props.geojson) {
updateGeoJson(props.geojson);
}
if (props.alprs.length) {
updateMarkers(props.alprs);
} else {
emit('update:bounds', map.getBounds());
}
if (props.currentLocation) {
updateCurrentLocation();
}
}
function updateMarkers(newAlprs: ALPR[]): void {
const currentIds = new Set(markerMap.keys());
const nonexistingAlprs = newAlprs.filter(alpr => !currentIds.has(alpr.id));
// Add markers
for (const alpr of nonexistingAlprs) {
if (!currentIds.has(alpr.id)) {
// Add new marker
const marker = createMarker(alpr);
bindPopup(marker, alpr);
markerMap.set(alpr.id, marker);
circlesLayer.addLayer(marker);
}
}
// Update cluster layer
clusterLayer.clearLayers();
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();
if (props.currentLocation) {
const marker = L.circleMarker([props.currentLocation[0], props.currentLocation[1]], {
radius: 10,
color: '#ffffff',
fillColor: '#007bff',
fillOpacity: 1,
weight: 4
}).bindPopup('Current Location');
currentLocationLayer.addLayer(marker);
map.addLayer(currentLocationLayer);
}
}
function updateClusteringBehavior(): void {
if (!clusterLayer || !map) return;
// Use shouldCluster computed value which handles both zoom and user preference
const newDisableZoom = shouldCluster.value ? CLUSTER_DISABLE_ZOOM : 1;
// Remove the cluster layer, update its options, and re-add it
if (map.hasLayer(clusterLayer)) {
map.removeLayer(clusterLayer);
}
// Create new cluster layer with updated settings
const newClusterLayer = L.markerClusterGroup({
chunkedLoading: true,
disableClusteringAtZoom: newDisableZoom,
removeOutsideVisibleBounds: true,
maxClusterRadius: 60,
spiderfyOnEveryZoom: false,
spiderfyOnMaxZoom: false,
});
// Transfer all markers to the new cluster layer
newClusterLayer.addLayer(circlesLayer);
// Replace the old cluster layer
clusterLayer = newClusterLayer;
map.addLayer(clusterLayer);
}
function dismissZoomWarning(): void {
zoomWarningDismissed.value = true;
}
// Lifecycle Hooks
onMounted(() => {
initializeMap();
// Watch for clustering toggle
watch(clusteringEnabled, () => {
updateClusteringBehavior();
});
// Watch for zoom-based clustering changes
watch(shouldCluster, () => {
updateClusteringBehavior();
});
// Watch for prop changes
watch(() => props.center, (newCenter: any) => {
if (!isInternalUpdate.value) {
isInternalUpdate.value = true;
map.setView(newCenter, map.getZoom(), { animate: false });
setTimeout(() => {
isInternalUpdate.value = false;
}, 0);
}
});
watch(() => props.zoom, (newZoom: number) => {
if (!isInternalUpdate.value) {
isInternalUpdate.value = true;
currentZoom.value = newZoom;
map.setZoom(newZoom);
setTimeout(() => {
isInternalUpdate.value = false;
}, 0);
}
});
watch(() => props.alprs, (newAlprs) => {
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();
});
});
onBeforeUnmount(() => {
map?.remove();
});
function registerMapEvents() {
map.on('moveend', () => {
if (!isInternalUpdate.value) {
emit('update:center', map.getCenter());
emit('update:zoom', map.getZoom());
emit('update:bounds', map.getBounds());
}
});
map.on('zoomend', () => {
if (!isInternalUpdate.value) {
const oldZoom = currentZoom.value;
const newZoom = map.getZoom();
currentZoom.value = newZoom;
// Reset zoom warning when user zooms in enough
if (newZoom >= 12) {
zoomWarningDismissed.value = false;
}
}
});
}
</script>
<style scoped>
@import 'leaflet/dist/leaflet.css';
@import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
@import 'leaflet.markercluster/dist/MarkerCluster.css';
#map {
width: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.topleft {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
}
.topright {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
}
.bottomright {
position: absolute;
bottom: 25px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.clustering-status-bar {
position: fixed;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(33, 33, 33, 0.9);
color: white;
padding: 12px 20px;
border-radius: 25px;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
min-width: 280px;
max-width: 90vw;
text-align: center;
}
/* Mobile-specific improvements */
@media (max-width: 768px) {
.clustering-status-bar {
margin: 0 10px;
min-width: unset;
max-width: calc(100vw - 20px);
}
.topright {
top: 75px;
}
}
</style>
<style> /* (Global) */
/* Disables clicks on the main wrappers */
.leaflet-marker-icon.leaflet-interactive:not(.marker-cluster), .svgMarker {
pointer-events: none;
cursor: default;
}
.svgMarker {
pointer-events: none;
cursor: default;
}
/* Enables clicks only on actual SVG path */
.someSVGpath {
pointer-events: all;
cursor: pointer;
}
</style>