diff --git a/webapp/src/components/LeafletMap.vue b/webapp/src/components/LeafletMap.vue
index f7be1d9..988fcc9 100644
--- a/webapp/src/components/LeafletMap.vue
+++ b/webapp/src/components/LeafletMap.vue
@@ -7,36 +7,55 @@
-
-
-
+
+
- {{ shouldCluster ? 'mdi-scatter-plot' : 'mdi-chart-bubble' }}
-
- {{ clusteringEnabled ? 'Disable Clustering' : 'Enable Clustering' }}
-
-
-
+
+
+ mdi-chart-bubble
+ Group
+
+
+
+
+
+
+
-
+
- mdi-information
+ {{ statusBarIcon }}
- Clustering forced ON at this zoom level for performance.
+ {{ statusBarMessage }}
+
+ mdi-close
+
@@ -62,11 +81,16 @@ const CLUSTER_DISABLE_ZOOM = 16; // Clustering disabled at zoom 16 and above
// Internal State Management
const markerMap = new Map();
const isInternalUpdate = ref(false);
-const isFullScreen = computed(() => useRoute().query.fullscreen === 'true');
+const route = useRoute();
+const isFullScreen = computed(() => route.query.fullscreen === 'true');
// Clustering Control
const clusteringEnabled = ref(true);
const currentZoom = ref(0);
+const userHasInteractedWithClustering = ref(false);
+const recentZoomActions = ref<{ timestamp: number; zoom: number; action: 'in' | 'out' }[]>([]);
+const mapInteractionCount = ref(0);
+const zoomWarningDismissed = ref(false);
// Computed property to determine if clustering should be active based on zoom and user preference
const shouldCluster = computed(() => {
@@ -80,7 +104,52 @@ const shouldCluster = computed(() => {
// Show status when clustering is disabled by user but forced ON due to zoom
const showAutoDisabledStatus = computed(() => {
- return !clusteringEnabled.value && currentZoom.value < 12;
+ return !clusteringEnabled.value && currentZoom.value < 12 && !zoomWarningDismissed.value;
+});
+
+// Detect when user might want to see individual markers
+const shouldSuggestDeclustering = computed(() => {
+ if (userHasInteractedWithClustering.value || currentZoom.value < 12) {
+ return false;
+ }
+
+ // Check for rapid zoom-in behavior (suggests user wants detail)
+ const now = Date.now();
+ const recentZooms = recentZoomActions.value.filter(action => now - action.timestamp < 10000); // Last 10 seconds
+ const rapidZoomIns = recentZooms.filter(action => action.action === 'in').length;
+
+ // Suggest if user has zoomed in multiple times and is at a level where they can see individual markers
+ return rapidZoomIns >= 2 && currentZoom.value >= 14 && clusteringEnabled.value;
+});
+
+// Unified status bar logic
+const showStatusBar = computed(() => {
+ return showAutoDisabledStatus.value || shouldSuggestDeclustering.value;
+});
+
+const statusBarIcon = computed(() => {
+ if (showAutoDisabledStatus.value) return 'mdi-information';
+ if (shouldSuggestDeclustering.value) return 'mdi-lightbulb-on';
+ return 'mdi-information';
+});
+
+const statusBarMessage = computed(() => {
+ if (showAutoDisabledStatus.value) {
+ return 'Camera grouping is on for performance at this zoom level.';
+ }
+ if (shouldSuggestDeclustering.value) {
+ return 'Want to see individual cameras? Try the grouping button.';
+ }
+ return '';
+});
+
+const statusBarCanDismiss = computed(() => {
+ return shouldSuggestDeclustering.value || showAutoDisabledStatus.value;
+});
+
+// Show button glow when status bar is giving hints about the button
+const shouldGlowButton = computed(() => {
+ return shouldSuggestDeclustering.value;
});
const props = defineProps({
@@ -298,7 +367,6 @@ function updateCurrentLocation(): void {
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;
@@ -325,8 +393,58 @@ function updateClusteringBehavior(): void {
map.addLayer(clusterLayer);
}
+// User Interaction Functions
+function onToggleChange(): void {
+ userHasInteractedWithClustering.value = true;
+ // Persist user preference
+ localStorage.setItem('deflock-clustering-preference', clusteringEnabled.value.toString());
+}
+
+function dismissSuggestion(): void {
+ userHasInteractedWithClustering.value = true;
+ // Don't show suggestions for the rest of this session
+ localStorage.setItem('deflock-clustering-preference', clusteringEnabled.value.toString());
+}
+
+function dismissZoomWarning(): void {
+ zoomWarningDismissed.value = true;
+}
+
+function dismissStatusBar(): void {
+ if (shouldSuggestDeclustering.value) {
+ dismissSuggestion();
+ } else if (showAutoDisabledStatus.value) {
+ dismissZoomWarning();
+ }
+}
+
+function trackZoomAction(action: 'in' | 'out'): void {
+ const now = Date.now();
+ recentZoomActions.value.push({
+ timestamp: now,
+ zoom: currentZoom.value,
+ action
+ });
+
+ // Keep only recent actions (last 30 seconds)
+ recentZoomActions.value = recentZoomActions.value.filter(
+ actionItem => now - actionItem.timestamp < 30000
+ );
+}
+
+function trackMapInteraction(): void {
+ mapInteractionCount.value++;
+}
+
// Lifecycle Hooks
onMounted(() => {
+ // Initialize clustering preference from localStorage if available
+ const storedPreference = localStorage.getItem('deflock-clustering-preference');
+ if (storedPreference !== null) {
+ clusteringEnabled.value = storedPreference === 'true';
+ userHasInteractedWithClustering.value = true;
+ }
+
initializeMap();
// Watch for clustering toggle
@@ -380,14 +498,35 @@ function registerMapEvents() {
emit('update:center', map.getCenter());
emit('update:zoom', map.getZoom());
emit('update:bounds', map.getBounds());
+ trackMapInteraction();
}
});
map.on('zoomend', () => {
if (!isInternalUpdate.value) {
- currentZoom.value = map.getZoom();
+ 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;
+ }
+
+ // Track zoom direction for behavioral inference
+ if (newZoom > oldZoom) {
+ trackZoomAction('in');
+ } else if (newZoom < oldZoom) {
+ trackZoomAction('out');
+ }
+
+ trackMapInteraction();
}
});
+
+ // Track general map interactions
+ map.on('click', trackMapInteraction);
+ map.on('drag', trackMapInteraction);
}
@@ -411,6 +550,13 @@ function registerMapEvents() {
z-index: 1000;
}
+.topright {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 1000;
+}
+
.bottomright {
position: absolute;
bottom: 50px; /* hack */
@@ -422,16 +568,6 @@ function registerMapEvents() {
gap: 8px;
}
-.clustering-toggle-btn {
- backdrop-filter: blur(10px);
- background: rgba(255, 255, 255, 0.95) !important;
- border: 1px solid rgba(255, 255, 255, 0.3);
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
- position: absolute;
- bottom: 50px;
- right: 10px;
-}
-
.clustering-status-bar {
position: fixed;
bottom: 10px;
@@ -451,6 +587,19 @@ function registerMapEvents() {
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: 60px;
+ }
+}