From ad9e0aafd94623efe5dd5d6acdefc12857e4d3e5 Mon Sep 17 00:00:00 2001 From: Will Freeman Date: Thu, 9 Oct 2025 16:32:02 -0600 Subject: [PATCH 1/3] allow declustering, update council page (#48) * option to disable clustering * update wins, add videos, clean up council page * improve grouping toggle * some cleanup --- webapp/src/App.vue | 26 ---- webapp/src/components/LeafletMap.vue | 172 ++++++++++++++++++++- webapp/src/views/CouncilView.vue | 217 +++++++++++++++++++++------ 3 files changed, 344 insertions(+), 71 deletions(-) diff --git a/webapp/src/App.vue b/webapp/src/App.vue index 70293d3..37d26af 100644 --- a/webapp/src/App.vue +++ b/webapp/src/App.vue @@ -7,7 +7,6 @@ import { useDiscordIntercept } from '@/composables/useDiscordIntercept'; const theme = useTheme(); const router = useRouter(); -const snackbar = ref({ show: false, text: '' }); const isDark = computed(() => theme.name.value === 'dark'); const isFullscreen = computed(() => router.currentRoute.value?.query.fullscreen === 'true'); const { showDialog, discordUrl, interceptDiscordLinks } = useDiscordIntercept(); @@ -16,13 +15,6 @@ function toggleTheme() { const newTheme = theme.global.name.value === 'dark' ? 'light' : 'dark'; theme.global.name.value = newTheme; localStorage.setItem('theme', newTheme); - - if (newTheme === 'dark' && router.currentRoute.value.path === '/map') { - snackbar.value = { - show: true, - text: "Dark maps aren't available yet :(" - }; - } } function handleDiscordProceed(url: string) { @@ -237,24 +229,6 @@ watch(() => theme.global.name.value, (newTheme) => { - - mdi-theme-light-dark - {{ snackbar.text }} - - - +
+ + + +
+ mdi-chart-bubble + Grouping + +
+
+
+
+
+ + + +
+ mdi-information + + Camera grouping is on for performance at this zoom level. + + + mdi-close + +
+
@@ -28,11 +71,33 @@ 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(); 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 zoomWarningDismissed = ref(false); + +// 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: { @@ -183,7 +248,7 @@ function initializeMap() { clusterLayer = L.markerClusterGroup({ chunkedLoading: true, - disableClusteringAtZoom: 16, + disableClusteringAtZoom: shouldCluster.value ? CLUSTER_DISABLE_ZOOM : 1, removeOutsideVisibleBounds: true, maxClusterRadius: 60, spiderfyOnEveryZoom: false, @@ -193,6 +258,9 @@ function initializeMap() { circlesLayer = L.featureGroup(); currentLocationLayer = L.featureGroup(); + // Initialize current zoom + currentZoom.value = props.zoom; + map.addLayer(clusterLayer); registerMapEvents(); @@ -244,10 +312,52 @@ 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; + + // 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) { @@ -262,6 +372,7 @@ onMounted(() => { watch(() => props.zoom, (newZoom: number) => { if (!isInternalUpdate.value) { isInternalUpdate.value = true; + currentZoom.value = newZoom; map.setZoom(newZoom); setTimeout(() => { isInternalUpdate.value = false; @@ -290,6 +401,19 @@ 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; + } + } + }); } @@ -313,11 +437,55 @@ function registerMapEvents() { z-index: 1000; } +.topright { + position: absolute; + top: 10px; + right: 10px; + z-index: 1000; +} + .bottomright { position: absolute; bottom: 50px; /* hack */ right: 60px; /* hack */ 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: 60px; + } } diff --git a/webapp/src/views/CouncilView.vue b/webapp/src/views/CouncilView.vue index 59d4ba2..834bf87 100644 --- a/webapp/src/views/CouncilView.vue +++ b/webapp/src/views/CouncilView.vue @@ -60,15 +60,29 @@ -
- mdi-trophy -

Timeline of Recent Victories

+
+
+ mdi-trophy +

Timeline of Recent Victories

+
+ + {{ citiesRejectingFlock.length }} Recent Wins +
- -
+
mdi-check-bold @@ -81,11 +95,40 @@ {{ city.monthYear }}

- {{ city.outcome }} + + + {{ city.outcome }} + + +

+ + {{ city.outcomeNotes }} + +
+ +
+ + {{ showAllVictories ? 'Show Less' : `View All ${citiesRejectingFlock.length} Victories` }} + +
@@ -189,33 +232,22 @@ Example Videos - + -
- - mdi-play - + + mdi-play + + +
+

{{ video.location }}

+

City Council Meeting

- -

{{ video.location }}

-

{{ video.description }}

- - - Watch on YouTube - @@ -230,17 +262,35 @@