From 761eb66d1f327036fbbc94502d398191c3908be2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Jun 2025 16:28:20 -0500 Subject: [PATCH] add spot clustering --- assets/css/app.css | 78 +++++++++++++ assets/js/app.js | 106 ++++++++++++++---- .../controllers/page_html/map.html.heex | 12 ++ lib/aprs_web/live/map_live/index.ex | 10 ++ 4 files changed, 185 insertions(+), 21 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 289d6ee..ca5aab7 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -36,3 +36,81 @@ body.home-page main > div { max-width: none; height: 100%; } + +/* Marker cluster styles */ +.marker-cluster-small { + background-color: rgba(16, 185, 129, 0.8); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} +.marker-cluster-small div { + background-color: rgba(16, 185, 129, 0.9); + box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3); +} + +.marker-cluster-medium { + background-color: rgba(59, 130, 246, 0.8); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} +.marker-cluster-medium div { + background-color: rgba(59, 130, 246, 0.9); + box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3); +} + +.marker-cluster-large { + background-color: rgba(239, 68, 68, 0.8); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} +.marker-cluster-large div { + background-color: rgba(239, 68, 68, 0.9); + box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3); +} + +/* Custom cluster icon styles */ +.marker-cluster { + background-clip: padding-box; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.8); +} +.marker-cluster div { + width: 30px; + height: 30px; + margin-left: 5px; + margin-top: 5px; + text-align: center; + border-radius: 50%; + font: + 13px "Helvetica Neue", + Arial, + Helvetica, + sans-serif; + color: white; + font-weight: bold; + line-height: 30px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); +} +.marker-cluster span { + line-height: 30px; +} + +/* Cluster animations */ +.leaflet-cluster-anim .leaflet-marker-icon, +.leaflet-cluster-anim .leaflet-marker-shadow { + transition: + transform 0.3s ease-out, + opacity 0.3s ease-out; +} + +/* Hover effect for clusters */ +.marker-cluster:hover { + transform: scale(1.1); + z-index: 1000 !important; +} + +/* APRS marker styles */ +.aprs-marker { + transition: transform 0.2s ease-out; +} +.aprs-marker:hover { + transform: scale(1.2); + z-index: 1000 !important; +} diff --git a/assets/js/app.js b/assets/js/app.js index e4efb23..2e0c6a7 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -40,6 +40,52 @@ Hooks.APRSMap = { maxZoom: 19, }).addTo(map); + // Check if MarkerCluster plugin is available + if (typeof L.markerClusterGroup === "function") { + // Create marker cluster group + this.markerClusterGroup = L.markerClusterGroup({ + chunkedLoading: true, + maxClusterRadius: function (zoom) { + // Adjust cluster radius based on zoom level + // Tighter clustering when zoomed out, looser when zoomed in + if (zoom <= 5) return 120; + if (zoom <= 8) return 80; + if (zoom <= 11) return 60; + if (zoom <= 14) return 40; + return 20; + }, + spiderfyOnMaxZoom: true, + showCoverageOnHover: true, + zoomToBoundsOnClick: true, + removeOutsideVisibleBounds: true, + animate: true, + animateAddingMarkers: true, + disableClusteringAtZoom: 16, + iconCreateFunction: function (cluster) { + const childCount = cluster.getChildCount(); + let c = " marker-cluster-"; + if (childCount < 10) { + c += "small"; + } else if (childCount < 100) { + c += "medium"; + } else { + c += "large"; + } + return new L.DivIcon({ + html: "
" + childCount + "
", + className: "marker-cluster" + c, + iconSize: new L.Point(40, 40), + }); + }, + }); + + // Add cluster group to map + map.addLayer(this.markerClusterGroup); + } else { + console.warn("Leaflet MarkerCluster plugin not loaded, falling back to regular markers"); + this.markerClusterGroup = null; + } + // Store markers to avoid duplicates this.markers = new Map(); this.packetCount = 0; @@ -88,10 +134,15 @@ Hooks.APRSMap = { }, clearAllMarkers() { - // Remove all markers from the map - this.markers.forEach((marker) => { - this.map.removeLayer(marker); - }); + // Remove all markers from the cluster group or map + if (this.markerClusterGroup) { + this.markerClusterGroup.clearLayers(); + } else { + // Fallback: remove markers directly from map + this.markers.forEach((marker) => { + this.map.removeLayer(marker); + }); + } this.markers.clear(); this.packetCount = 0; const counterElement = document.getElementById("packet-count"); @@ -101,31 +152,25 @@ Hooks.APRSMap = { }, removeMarkersOutsideBounds(bounds) { - // Remove markers that are outside the current bounds - const markersToRemove = []; + // With clustering, the cluster group handles visibility automatically + // We'll just track the count of markers within bounds + let visibleCount = 0; this.markers.forEach((marker, callsign) => { const latLng = marker.getLatLng(); - if (!bounds.contains(latLng)) { - this.map.removeLayer(marker); - markersToRemove.push(callsign); + if (bounds.contains(latLng)) { + visibleCount++; } }); - // Remove from our tracking map - markersToRemove.forEach((callsign) => { - this.markers.delete(callsign); - this.packetCount--; - }); - - // Update counter + // Update counter to show visible markers const counterElement = document.getElementById("packet-count"); if (counterElement) { - counterElement.textContent = this.packetCount; + counterElement.textContent = visibleCount; } // Notify server of the updated packet count - this.pushEvent("update_packet_count", { count: this.packetCount }); + this.pushEvent("update_packet_count", { count: visibleCount }); }, addPacketMarker(packet) { @@ -168,8 +213,17 @@ Hooks.APRSMap = { if (this.markers.has(callsign)) { // Update existing marker const existingMarker = this.markers.get(callsign); - existingMarker.setLatLng([lat, lng]); - existingMarker.setPopupContent(popupContent); + if (this.markerClusterGroup) { + // Remove and re-add to cluster group + this.markerClusterGroup.removeLayer(existingMarker); + existingMarker.setLatLng([lat, lng]); + existingMarker.setPopupContent(popupContent); + this.markerClusterGroup.addLayer(existingMarker); + } else { + // Fallback: just update position + existingMarker.setLatLng([lat, lng]); + existingMarker.setPopupContent(popupContent); + } } else { // Create new marker const icon = this.createAPRSIcon( @@ -177,7 +231,14 @@ Hooks.APRSMap = { packet["data_extended"]["symbol_code"] || ">", ); - const marker = L.marker([lat, lng], { icon: icon }).addTo(this.map).bindPopup(popupContent); + const marker = L.marker([lat, lng], { icon: icon }).bindPopup(popupContent); + + // Add to cluster group or directly to map + if (this.markerClusterGroup) { + this.markerClusterGroup.addLayer(marker); + } else { + marker.addTo(this.map); + } this.markers.set(callsign, marker); console.log("New marker added for:", callsign, "Total markers:", this.markers.size); @@ -206,6 +267,9 @@ Hooks.APRSMap = { destroyed() { console.log("APRSMap hook destroyed"); + if (this.markerClusterGroup) { + this.markerClusterGroup.clearLayers(); + } if (this.map) { console.log("Removing map instance"); this.map.remove(); diff --git a/lib/aprs_web/controllers/page_html/map.html.heex b/lib/aprs_web/controllers/page_html/map.html.heex index 79b973b..29b62f1 100644 --- a/lib/aprs_web/controllers/page_html/map.html.heex +++ b/lib/aprs_web/controllers/page_html/map.html.heex @@ -4,6 +4,14 @@ integrity="sha256-kLaT2GOSpHechhsozzB+flnD+zUyjE2LlfWPgU04xyI=" crossorigin="" /> + + +
diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index aab5817..8abb8c2 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -118,12 +118,22 @@ defmodule AprsWeb.MapLive.Index do integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /> + + +