diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue index 14637ad..07baf6c 100644 --- a/webapp/src/components/LeafletMap.vue +++ b/webapp/src/components/LeafletMap.vue @@ -18,18 +18,12 @@ mdi-chart-bubble Grouping - + - + @@ -38,13 +32,7 @@ mdi-map-outline City Boundaries - + @@ -54,25 +42,15 @@
- + -
+
mdi-information Camera grouping is on for performance at this zoom level. - + mdi-close
@@ -140,13 +118,17 @@ const props = defineProps({ default: () => [], }, geojson: { - type : Object as PropType, + type: Object as PropType, default: null, }, currentLocation: { type: Object as PropType<[number, number] | null>, default: null, }, + route: { + type: Object as PropType, + default: null, + }, }); const emit = defineEmits(['update:center', 'update:zoom', 'update:bounds']); @@ -156,9 +138,11 @@ let map: L.Map; let circlesLayer: FeatureGroup; let clusterLayer: MarkerClusterGroup; let currentLocationLayer: FeatureGroup; +let routeLayer: FeatureGroup; +let alprsOnRoute: ALPR[] = []; // Marker Creation Utilities -function createSVGMarkers(alpr: ALPR): string { +function createSVGMarkers(alpr: ALPR, marker_color: string = MARKER_COLOR): string { const orientationValues = (alpr.tags['camera:direction'] || alpr.tags.direction || '') .split(';') .map(val => parseDirectionValue(val.trim())); @@ -176,8 +160,8 @@ function createSVGMarkers(alpr: ALPR): string { return ` ${allDirectionsPath} - - + + `; @@ -185,7 +169,7 @@ function createSVGMarkers(alpr: ALPR): string { 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('-'); @@ -195,7 +179,7 @@ function parseDirectionValue(value: string): number { return calculateMidpointAngle(start, end); } } - + // Single value return parseDirectionSingle(value); } @@ -205,7 +189,7 @@ function parseDirectionSingle(value: string): number { if (/^\d+(\.\d+)?$/.test(value)) { return parseFloat(value); } - + // Try cardinal direction return cardinalToDegrees(value); } @@ -214,21 +198,21 @@ 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; } @@ -257,11 +241,11 @@ function cardinalToDegrees(cardinal: string): number { return CARDINAL_DIRECTIONS[upperCardinal] ?? parseFloat(cardinal) ?? 0; } -function createMarker(alpr: ALPR): Marker | CircleMarker { +function createMarker(alpr: ALPR, marker_color: string = MARKER_COLOR): Marker | CircleMarker { if (hasPlottableOrientation(alpr.tags.direction || alpr.tags['camera:direction'])) { const icon = L.divIcon({ className: 'leaflet-data-marker', - html: createSVGMarkers(alpr), + html: createSVGMarkers(alpr, marker_color), iconSize: [60, 60], iconAnchor: [30, 30], popupAnchor: [0, 0], @@ -271,10 +255,10 @@ function createMarker(alpr: ALPR): Marker | CircleMarker { return L.circleMarker([alpr.lat, alpr.lon], { fill: true, - fillColor: MARKER_COLOR, + fillColor: marker_color, fillOpacity: 0.6, stroke: true, - color: MARKER_COLOR, + color: marker_color, opacity: 1, radius: 8, weight: 3, @@ -314,23 +298,23 @@ function bindPopup(marker: L.CircleMarker | L.Marker, alpr: ALPR): L.CircleMarke 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()); }); @@ -359,6 +343,7 @@ function initializeMap() { circlesLayer = L.featureGroup(); currentLocationLayer = L.featureGroup(); + routeLayer = L.featureGroup(); // Initialize current zoom currentZoom.value = props.zoom; @@ -439,16 +424,188 @@ function updateCurrentLocation(): void { } } +function getBearing(from: L.LatLng, to: L.LatLng): number { + const fromLat = from.lat * Math.PI / 180; + const fromLng = from.lng * Math.PI / 180; + const toLat = to.lat * Math.PI / 180; + const toLng = to.lng * Math.PI / 180; + + const y = Math.sin(toLng - fromLng) * Math.cos(toLat); + const x = Math.cos(fromLat) * Math.sin(toLat) - Math.sin(fromLat) * Math.cos(toLat) * Math.cos(toLng - fromLng); + const bearing = Math.atan2(y, x) * 180 / Math.PI; + + return (bearing + 360) % 360; // Normalize to 0-360 +} + +function hideMarkersInCluster(alprIds: string[]): void { + for (const alprId of alprIds) { + const marker = markerMap.get(alprId); + if (marker) { + // Hide the marker by setting its opacity to 0 + if (marker instanceof L.Marker) { + marker.setOpacity(0); + marker.getElement()?.style.setProperty('pointer-events', 'none'); + } else if (marker instanceof L.CircleMarker) { + marker.setStyle({ fillOpacity: 0, opacity: 0 }); + } + } + } +} + +function showMarkersInCluster(alprIds: string[]): void { + for (const alprId of alprIds) { + const marker = markerMap.get(alprId); + if (marker) { + // Show the marker by restoring opacity + if (marker instanceof L.Marker) { + marker.setOpacity(1); + marker.getElement()?.style.removeProperty('pointer-events'); + } else if (marker instanceof L.CircleMarker) { + marker.setStyle({ fillOpacity: 0.6, opacity: 1 }); + } + } + } +} + +function updateRoute(newRoute: GeoJSON.LineString | null): void { + routeLayer.clearLayers(); + + // Show previously hidden route ALPRs back in clustering layer + const previousRouteAlprIds = alprsOnRoute.map(alpr => alpr.id); + showMarkersInCluster(previousRouteAlprIds); + alprsOnRoute.length = 0; // clear previous + + if (newRoute) { + // add the line + const geoJsonLayer = L.geoJSON(newRoute, { + style: { + color: 'red', // line color + weight: 5, // line width + opacity: 0.8, // line opacity + } + }); + routeLayer.addLayer(geoJsonLayer); + + // add a marker at the ends of the route + const coord_len = newRoute.coordinates.length + var startMarker = L.marker([newRoute.coordinates[0][1], newRoute.coordinates[0][0]]); + var endMarker = L.marker([newRoute.coordinates[coord_len - 1][1], newRoute.coordinates[coord_len - 1][0]]); + routeLayer.addLayer(startMarker); + routeLayer.addLayer(endMarker); + + var nearbyAlprRadius = 250 + var watchingAlprRadius = 100 + var watchingAlprHalfAngle = 45 + + // resample coordinates + for (var i = 1; i < coord_len; i++) { + var start = L.latLng(newRoute.coordinates[i - 1][1], newRoute.coordinates[i - 1][0]); + var end = L.latLng(newRoute.coordinates[i][1], newRoute.coordinates[i][0]); + var distance = start.distanceTo(end); + if (distance > watchingAlprRadius / 4) { + var numExtraPoints = Math.floor(distance / watchingAlprRadius); + for (var j = 1; j <= numExtraPoints; j++) { + var fraction = j / (numExtraPoints + 1); + var lat = start.lat + (end.lat - start.lat) * fraction; + var lon = start.lng + (end.lng - start.lng) * fraction; + newRoute.coordinates.splice(i, 0, [lon, lat]); + i++; + } + } + } + + // TODO improve performance for long routes + // coarse filter for ALPRs + var candidateAlprs = []; + var bounds = geoJsonLayer.getBounds(); + bounds.pad(0.05); + console.log("getting candidates"); + for (var alpr of props.alprs) { + if (bounds.contains(L.latLng(alpr.lat, alpr.lon))) { + candidateAlprs.push(alpr); + } + } + + // nearby filter for ALPRs + var nearbyAlprs: ALPR[] = []; + for (var alpr of candidateAlprs) { + for (var coord of newRoute.coordinates) { + if (L.latLng(alpr.lat, alpr.lon).distanceTo(L.latLng(coord[1], coord[0])) < nearbyAlprRadius) { + nearbyAlprs.push(alpr); + break; + } + } + } + + // watching route filter for ALPRs + alprsOnRoute.length = 0; // clear previous + for (var alpr of nearbyAlprs) { + for (var coord of newRoute.coordinates) { + if (L.latLng(alpr.lat, alpr.lon).distanceTo(L.latLng(coord[1], coord[0])) < watchingAlprRadius) { + var addAlpr = false; + if (hasPlottableOrientation(alpr.tags.direction || alpr.tags['camera:direction'])) { + const orientationValues = (alpr.tags['camera:direction'] || alpr.tags.direction || '') + .split(';') + .map(val => parseDirectionValue(val.trim())); + const bearingToRoute = getBearing(L.latLng(alpr.lat, alpr.lon), L.latLng(coord[1], coord[0])); + for (var orientation of orientationValues) { + var angleDiff = Math.abs(orientation - bearingToRoute); + angleDiff = angleDiff > 180 ? 360 - angleDiff : angleDiff; + if (angleDiff < watchingAlprHalfAngle) { + addAlpr = true; + break; + } + } + } + else { + addAlpr = true; + } + if (addAlpr) { + alprsOnRoute.push(alpr); + const marker = createMarker(alpr, 'red') + routeLayer.addLayer(marker); + bindPopup(marker, alpr); + // Don't add to markerMap since it's only for route display + break; + } + } + } + } + + // Add click event to route line to show statistics popup + const statsContent = `
${alprsOnRoute.length} ALPRs are watching this route.
There are ${nearbyAlprs.length} ALPRs within a block.
`; + geoJsonLayer.on('click', function (e) { + L.popup() + .setLatLng(e.latlng) + .setContent(statsContent) + .openOn(map); + }); + + // add statistics popup (initial display - remove this since we'll show on click) + var popup = L.popup() + .setLatLng([newRoute.coordinates[~~(coord_len / 2)][1], newRoute.coordinates[~~(coord_len / 2)][0]]) + .setContent(`
${alprsOnRoute.length} ALPRs are watching this route.
There are ${nearbyAlprs.length} ALPRs within a block.
`); + routeLayer.addLayer(popup); + + map.addLayer(routeLayer); + map.fitBounds(bounds); + + // Hide route ALPRs from clustering layer to avoid duplicates + const routeAlprIds = alprsOnRoute.map(alpr => alpr.id); + hideMarkersInCluster(routeAlprIds); + } +} + 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, @@ -458,10 +615,10 @@ function updateClusteringBehavior(): void { 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); @@ -479,7 +636,7 @@ onMounted(() => { watch(clusteringEnabled, () => { updateClusteringBehavior(); }); - + // Watch for zoom-based clustering changes watch(shouldCluster, () => { updateClusteringBehavior(); @@ -524,6 +681,10 @@ onMounted(() => { watch(() => props.currentLocation, () => { updateCurrentLocation(); }); + + watch(() => props.route, (newRoute) => { + updateRoute(newRoute) + }); }); onBeforeUnmount(() => { @@ -538,13 +699,13 @@ function registerMapEvents() { 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; @@ -583,8 +744,10 @@ function registerMapEvents() { .bottomright { position: absolute; - bottom: 50px; /* hack */ - right: 60px; /* hack */ + bottom: 50px; + /* hack */ + right: 60px; + /* hack */ z-index: 1000; display: flex; flex-direction: column; @@ -626,12 +789,15 @@ function registerMapEvents() { } -