From 645bfe0bb6963bf522d7cfc03be0f98514a3923d Mon Sep 17 00:00:00 2001 From: gnw2t Date: Wed, 10 Dec 2025 21:28:42 -0700 Subject: [PATCH 1/7] toggle between search modes --- webapp/src/components/LeafletMap.vue | 119 ++++++++------ webapp/src/services/apiService.ts | 6 + webapp/src/views/Map.vue | 238 ++++++++++++++++++++------- 3 files changed, 248 insertions(+), 115 deletions(-) diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue index 14637ad..9c35857 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,6 +138,7 @@ let map: L.Map; let circlesLayer: FeatureGroup; let clusterLayer: MarkerClusterGroup; let currentLocationLayer: FeatureGroup; +let routeLayer: FeatureGroup; // Marker Creation Utilities function createSVGMarkers(alpr: ALPR): string { @@ -185,7 +168,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 +178,7 @@ function parseDirectionValue(value: string): number { return calculateMidpointAngle(start, end); } } - + // Single value return parseDirectionSingle(value); } @@ -205,7 +188,7 @@ function parseDirectionSingle(value: string): number { if (/^\d+(\.\d+)?$/.test(value)) { return parseFloat(value); } - + // Try cardinal direction return cardinalToDegrees(value); } @@ -214,21 +197,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; } @@ -314,23 +297,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 +342,7 @@ function initializeMap() { circlesLayer = L.featureGroup(); currentLocationLayer = L.featureGroup(); + routeLayer = L.featureGroup(); // Initialize current zoom currentZoom.value = props.zoom; @@ -439,16 +423,34 @@ function updateCurrentLocation(): void { } } +function updateRoute(newRoute: GeoJSON.GeoJsonObject | null): void { + routeLayer.clearLayers(); + + if (newRoute) { + const geoJsonLayer = L.geoJSON(newRoute, { + style: { + weight: 4, + opacity: 1, + }, + interactive: false, + }); + routeLayer.addLayer(geoJsonLayer); + console.log("newRoute", newRoute) + // L.marker([geoJsonLayer.geometry,coordinates[0].location[1], newRoute.coordinates[0].location[0]]).bindPopup('Route Start').addTo(routeLayer); + map.addLayer(routeLayer); + } +} + 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 +460,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 +481,7 @@ onMounted(() => { watch(clusteringEnabled, () => { updateClusteringBehavior(); }); - + // Watch for zoom-based clustering changes watch(shouldCluster, () => { updateClusteringBehavior(); @@ -524,6 +526,12 @@ onMounted(() => { watch(() => props.currentLocation, () => { updateCurrentLocation(); }); + + watch(() => props.route, (newRoute) => { + // add custom geojson layer, properties + // console.log(newRoute) + updateRoute(newRoute) + }); }); onBeforeUnmount(() => { @@ -538,13 +546,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 +591,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 +636,15 @@ function registerMapEvents() { } - From bbbfa367f59c36fed0d90e99134c8bb0241e08b7 Mon Sep 17 00:00:00 2001 From: gnw2t Date: Wed, 10 Dec 2025 22:01:31 -0700 Subject: [PATCH 2/7] refinements --- webapp/src/components/LeafletMap.vue | 20 +++++++++++++++++--- webapp/src/views/Map.vue | 10 +++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue index 9c35857..c663f8a 100644 --- a/webapp/src/components/LeafletMap.vue +++ b/webapp/src/components/LeafletMap.vue @@ -423,10 +423,11 @@ function updateCurrentLocation(): void { } } -function updateRoute(newRoute: GeoJSON.GeoJsonObject | null): void { +function updateRoute(newRoute: GeoJSON.LineString | null): void { routeLayer.clearLayers(); if (newRoute) { + // add the line const geoJsonLayer = L.geoJSON(newRoute, { style: { weight: 4, @@ -435,8 +436,21 @@ function updateRoute(newRoute: GeoJSON.GeoJsonObject | null): void { interactive: false, }); routeLayer.addLayer(geoJsonLayer); - console.log("newRoute", newRoute) - // L.marker([geoJsonLayer.geometry,coordinates[0].location[1], newRoute.coordinates[0].location[0]]).bindPopup('Route Start').addTo(routeLayer); + + // 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); + + // add statistics popup + var popup = L.popup() + .setLatLng([newRoute.coordinates[~~(coord_len / 2)][1], newRoute.coordinates[~~(coord_len / 2)][0]]) + .setContent("It's the route.") + routeLayer.addLayer(popup); + + map.addLayer(routeLayer); } } diff --git a/webapp/src/views/Map.vue b/webapp/src/views/Map.vue index 11b6b53..f8025f8 100644 --- a/webapp/src/views/Map.vue +++ b/webapp/src/views/Map.vue @@ -186,7 +186,7 @@ function onRouteSearch() { geocodeQuery(routeStartInput.value, center.value) .then((result: any) => { if (!result) { - alert('No results found'); + alert('No results found for start location'); return; } const { lat: latStart, lon: lngStart } = result; @@ -194,13 +194,11 @@ function onRouteSearch() { geocodeQuery(routeEndInput.value, center.value) .then((result: any) => { if (!result) { - alert('No results found'); + alert('No results found for end location'); return; } const { lat: latEnd, lon: lngEnd } = result; - console.log(latStart, lngStart, latEnd, lngEnd) - routeQuery({ lat: latStart, lng: lngStart }, { lat: latEnd, lng: lngEnd }) .then((routeData) => { route.value = routeData.routes[0].geometry as GeoJSON.GeoJsonObject; @@ -217,8 +215,6 @@ function onRouteSearch() { searchQuery.value = routeStartInput.value + '>' + routeEndInput.value; // Store the successful search query updateURL(); // TODO have route generate from URL - routeStartInput.value = ''; // Clear the input field - routeEndInput.value = ''; }); }); } @@ -260,7 +256,7 @@ function updateURL() { // 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 baseHash = `#map=${zoom.value}/${center.value.lat.toFixed(6)}/${center.value.lng.toFixed(6)}/route=${isRouteMode.value}`; const maybeSuffix = encodedSearchValue ? `/${encodedSearchValue}` : ''; const newHash = baseHash + maybeSuffix; From a0d819256d5ad9ace4840d273fb2fd53042e4ab4 Mon Sep 17 00:00:00 2001 From: gnw2t Date: Thu, 11 Dec 2025 10:04:38 -0700 Subject: [PATCH 3/7] along route calcs --- webapp/src/components/LeafletMap.vue | 114 +++++++++++++++++++++++---- webapp/src/views/Map.vue | 1 + 2 files changed, 98 insertions(+), 17 deletions(-) diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue index c663f8a..acbb4f5 100644 --- a/webapp/src/components/LeafletMap.vue +++ b/webapp/src/components/LeafletMap.vue @@ -141,7 +141,7 @@ let currentLocationLayer: FeatureGroup; let routeLayer: FeatureGroup; // 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())); @@ -159,8 +159,8 @@ function createSVGMarkers(alpr: ALPR): string { return ` ${allDirectionsPath} - - + + `; @@ -240,11 +240,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], @@ -254,10 +254,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, @@ -423,18 +423,25 @@ 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 updateRoute(newRoute: GeoJSON.LineString | null): void { routeLayer.clearLayers(); if (newRoute) { // add the line - const geoJsonLayer = L.geoJSON(newRoute, { - style: { - weight: 4, - opacity: 1, - }, - interactive: false, - }); + const geoJsonLayer = L.geoJSON(newRoute); routeLayer.addLayer(geoJsonLayer); // add a marker at the ends of the route @@ -444,13 +451,86 @@ function updateRoute(newRoute: GeoJSON.LineString | null): void { 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++; + } + } + } + + // 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 + var watchingAlprs: ALPR[] = []; + 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) { + watchingAlprs.push(alpr); + routeLayer.addLayer(createMarker(alpr, 'red')); // TODO fix ordering/grouping interaction + break; + } + } + } + } + // add statistics popup var popup = L.popup() .setLatLng([newRoute.coordinates[~~(coord_len / 2)][1], newRoute.coordinates[~~(coord_len / 2)][0]]) - .setContent("It's the route.") + .setContent(`
${watchingAlprs.length} ALPRs are watching this route.
${nearbyAlprs.length} ALPRs are within a block.
`); routeLayer.addLayer(popup); - - map.addLayer(routeLayer); } } diff --git a/webapp/src/views/Map.vue b/webapp/src/views/Map.vue index f8025f8..f476211 100644 --- a/webapp/src/views/Map.vue +++ b/webapp/src/views/Map.vue @@ -364,6 +364,7 @@ onMounted(() => { width: calc(100vw - 22px) !important; } +/* TODO fix toggle dark mode */ @media (min-width: 600px) { .search-toggle { max-width: 320px !important; From 1d660e5668013cf9aacd8c243d2050a163d17e7d Mon Sep 17 00:00:00 2001 From: gnw2t Date: Thu, 18 Dec 2025 21:59:28 -0800 Subject: [PATCH 4/7] More marker settings --- webapp/src/components/LeafletMap.vue | 18 ++++++++++++------ webapp/src/views/Map.vue | 19 ++++++------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue index acbb4f5..2131e01 100644 --- a/webapp/src/components/LeafletMap.vue +++ b/webapp/src/components/LeafletMap.vue @@ -126,7 +126,7 @@ const props = defineProps({ default: null, }, route: { - type: Object as PropType, + type: Object as PropType, default: null, }, }); @@ -441,7 +441,13 @@ function updateRoute(newRoute: GeoJSON.LineString | null): void { if (newRoute) { // add the line - const geoJsonLayer = L.geoJSON(newRoute); + 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 @@ -472,6 +478,7 @@ function updateRoute(newRoute: GeoJSON.LineString | null): void { } } + // TODO improve performance for long routes // coarse filter for ALPRs var candidateAlprs = []; var bounds = geoJsonLayer.getBounds(); @@ -529,9 +536,10 @@ function updateRoute(newRoute: GeoJSON.LineString | null): void { // add statistics popup var popup = L.popup() .setLatLng([newRoute.coordinates[~~(coord_len / 2)][1], newRoute.coordinates[~~(coord_len / 2)][0]]) - .setContent(`
${watchingAlprs.length} ALPRs are watching this route.
${nearbyAlprs.length} ALPRs are within a block.
`); - routeLayer.addLayer(popup); + .setContent(`
${watchingAlprs.length} ALPRs are watching this route.
There are ${nearbyAlprs.length} ALPRs within a block.
`); + routeLayer.addLayer(popup); // TODO make clickable to appear (or sidebar?) map.addLayer(routeLayer); + map.fitBounds(bounds); } } @@ -622,8 +630,6 @@ onMounted(() => { }); watch(() => props.route, (newRoute) => { - // add custom geojson layer, properties - // console.log(newRoute) updateRoute(newRoute) }); }); diff --git a/webapp/src/views/Map.vue b/webapp/src/views/Map.vue index f476211..55e24c3 100644 --- a/webapp/src/views/Map.vue +++ b/webapp/src/views/Map.vue @@ -8,11 +8,11 @@
- + mdi-magnify Search - + mdi-directions Route @@ -92,7 +92,7 @@ const routeStartInput: Ref = ref(''); // For the route start input field const routeEndInput: Ref = ref(''); // For the route end input field const searchQuery: Ref = ref(''); // For URL and boundaries (persistent) const geojson: Ref = ref(null); -const route: Ref = ref(null); +const route: Ref = ref(null); const isRouteMode: Ref = ref(false); // Toggle between search and route mode const tilesStore = useTilesStore(); @@ -206,7 +206,6 @@ function onRouteSearch() { lat: (routeData.waypoints[0].location[1] + routeData.waypoints[1].location[1]) / 2, lng: (routeData.waypoints[0].location[0] + routeData.waypoints[1].location[0]) / 2, }; - setZoom(route.value); }) .catch((error) => { alert("Error fetching route data."); @@ -357,10 +356,9 @@ onMounted(() => { } .search-toggle { - background-color: rgba(25, 118, 210, 0.1) !important; - backdrop-filter: blur(10px); - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + /* background-color: rgb(255, 255, 255) !important; */ + /* backdrop-filter: blur(10px); */ + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); width: calc(100vw - 22px) !important; } @@ -372,11 +370,6 @@ onMounted(() => { } .search-toggle .v-btn { - background-color: transparent !important; flex: 1 !important; } - -.search-toggle .v-btn--active { - background-color: rgba(255, 255, 255, 0.95) !important; -} From 885c9654abc3587df07410f9ff7b9480c78ec241 Mon Sep 17 00:00:00 2001 From: gnw2t Date: Thu, 18 Dec 2025 22:53:10 -0800 Subject: [PATCH 5/7] Process url --- webapp/src/views/Map.vue | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/webapp/src/views/Map.vue b/webapp/src/views/Map.vue index 55e24c3..3e2a1a5 100644 --- a/webapp/src/views/Map.vue +++ b/webapp/src/views/Map.vue @@ -201,7 +201,7 @@ function onRouteSearch() { routeQuery({ lat: latStart, lng: lngStart }, { lat: latEnd, lng: lngEnd }) .then((routeData) => { - route.value = routeData.routes[0].geometry as GeoJSON.GeoJsonObject; + route.value = routeData.routes[0].geometry as GeoJSON.LineString; center.value = { lat: (routeData.waypoints[0].location[1] + routeData.waypoints[1].location[1]) / 2, lng: (routeData.waypoints[0].location[0] + routeData.waypoints[1].location[0]) / 2, @@ -211,8 +211,6 @@ function onRouteSearch() { alert("Error fetching route data."); console.debug("Error fetching route data:", error); }); - - searchQuery.value = routeStartInput.value + '>' + routeEndInput.value; // Store the successful search query updateURL(); // TODO have route generate from URL }); }); @@ -253,9 +251,16 @@ function updateURL() { const currentRoute = router.currentRoute.value; // URL encode searchQuery.value - const encodedSearchValue = searchQuery.value ? encodeURIComponent(searchQuery.value) : null; + let encodedSearchValue: string | null; + if (isRouteMode.value) { + encodedSearchValue = encodeURIComponent(routeStartInput.value) + '/' + encodeURIComponent(routeEndInput.value); + } else { + encodedSearchValue = searchQuery.value ? encodeURIComponent(searchQuery.value) : null; + } + + routeStartInput.value + '/' + routeEndInput.value - const baseHash = `#map=${zoom.value}/${center.value.lat.toFixed(6)}/${center.value.lng.toFixed(6)}/route=${isRouteMode.value}`; + const baseHash = `#map=${zoom.value}/${center.value.lat.toFixed(6)}/${center.value.lng.toFixed(6)}`; const maybeSuffix = encodedSearchValue ? `/${encodedSearchValue}` : ''; const newHash = baseHash + maybeSuffix; @@ -287,10 +292,15 @@ onMounted(() => { lat: parseFloat(parts[1]), lng: parseFloat(parts[2]), }; - if (parts.length >= 4 && parts[3]) { + if (parts.length == 4 && parts[3]) { searchQuery.value = decodeURIComponent(parts[3]); searchInput.value = searchQuery.value; // Populate input field with URL search query onSearch() + } else if (parts.length == 5 && parts[3] && parts[4]) { + isRouteMode.value = true; + routeStartInput.value = decodeURIComponent(parts[3]); + routeEndInput.value = decodeURIComponent(parts[4]); + onRouteSearch() } } } else { From 775f2c788562e034e8a2c7d12960fe6b8db7521a Mon Sep 17 00:00:00 2001 From: gnw2t Date: Thu, 18 Dec 2025 23:13:50 -0800 Subject: [PATCH 6/7] Avoid duplicating highlighted ALPRs --- webapp/src/components/LeafletMap.vue | 51 +++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue index 2131e01..8c6d0a4 100644 --- a/webapp/src/components/LeafletMap.vue +++ b/webapp/src/components/LeafletMap.vue @@ -139,6 +139,7 @@ let circlesLayer: FeatureGroup; let clusterLayer: MarkerClusterGroup; let currentLocationLayer: FeatureGroup; let routeLayer: FeatureGroup; +let alprsOnRoute: ALPR[] = []; // Marker Creation Utilities function createSVGMarkers(alpr: ALPR, marker_color: string = MARKER_COLOR): string { @@ -436,9 +437,44 @@ function getBearing(from: L.LatLng, to: L.LatLng): number { 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, { @@ -502,7 +538,7 @@ function updateRoute(newRoute: GeoJSON.LineString | null): void { } // watching route filter for ALPRs - var watchingAlprs: ALPR[] = []; + 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) { @@ -525,8 +561,11 @@ function updateRoute(newRoute: GeoJSON.LineString | null): void { addAlpr = true; } if (addAlpr) { - watchingAlprs.push(alpr); - routeLayer.addLayer(createMarker(alpr, 'red')); // TODO fix ordering/grouping interaction + 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; } } @@ -536,10 +575,14 @@ function updateRoute(newRoute: GeoJSON.LineString | null): void { // add statistics popup var popup = L.popup() .setLatLng([newRoute.coordinates[~~(coord_len / 2)][1], newRoute.coordinates[~~(coord_len / 2)][0]]) - .setContent(`
${watchingAlprs.length} ALPRs are watching this route.
There are ${nearbyAlprs.length} ALPRs within a block.
`); + .setContent(`
${alprsOnRoute.length} ALPRs are watching this route.
There are ${nearbyAlprs.length} ALPRs within a block.
`); routeLayer.addLayer(popup); // TODO make clickable to appear (or sidebar?) map.addLayer(routeLayer); map.fitBounds(bounds); + + // Hide route ALPRs from clustering layer to avoid duplicates + const routeAlprIds = alprsOnRoute.map(alpr => alpr.id); + hideMarkersInCluster(routeAlprIds); } } From 7d616a70e07923b88e011d676566ee3b5eb18b28 Mon Sep 17 00:00:00 2001 From: gnw2t Date: Thu, 18 Dec 2025 23:16:15 -0800 Subject: [PATCH 7/7] Make route info clickable --- webapp/src/components/LeafletMap.vue | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue index 8c6d0a4..07baf6c 100644 --- a/webapp/src/components/LeafletMap.vue +++ b/webapp/src/components/LeafletMap.vue @@ -572,11 +572,21 @@ function updateRoute(newRoute: GeoJSON.LineString | null): void { } } - // add statistics popup + // 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); // TODO make clickable to appear (or sidebar?) + routeLayer.addLayer(popup); + map.addLayer(routeLayer); map.fitBounds(bounds);