From aaf92d90453863fa8eee592e380558c6aa0adff2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 10 Jul 2025 14:26:01 -0500 Subject: [PATCH] add heatmap on zoom out --- assets/js/map.ts | 199 ++++++++++++++++-- assets/js/types/map.d.ts | 8 + lib/aprsme/packets/clustering.ex | 125 +++++++++++ .../components/layouts/root.html.heex | 8 + lib/aprsme_web/live/map_live/index.ex | 167 +++++++++++++-- test/aprsme/packets/clustering_test.exs | 112 ++++++++++ 6 files changed, 579 insertions(+), 40 deletions(-) create mode 100644 lib/aprsme/packets/clustering.ex create mode 100644 test/aprsme/packets/clustering_test.exs diff --git a/assets/js/map.ts b/assets/js/map.ts index 11b1d28..91c3f8c 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -10,7 +10,9 @@ import type { import type { Map as LeafletMap, Marker, TileLayer, LayerGroup, DivIcon, LatLngBounds } from 'leaflet'; // Declare Leaflet as a global variable with proper typing -declare const L: typeof import('leaflet'); +declare const L: typeof import('leaflet') & { + heatLayer?: (latlngs: Array<[number, number, number?]>, options?: any) => any; +}; declare const OverlappingMarkerSpiderfier: any; // Import trail management functionality @@ -204,6 +206,25 @@ let MapAPRSMap = { self.markers = new Map(); self.markerLayer = L.layerGroup().addTo(self.map); + // Create heat layer (hidden by default) + try { + if (L.heatLayer) { + self.heatLayer = L.heatLayer([], { + radius: 25, + blur: 15, + maxZoom: 8, + gradient: { + 0.4: 'blue', + 0.65: 'lime', + 0.85: 'yellow', + 1.0: 'red' + } + }); + } + } catch (error) { + console.error("Error creating heat layer:", error); + } + // Create trail layer and manager const trailLayer = L.layerGroup().addTo(self.map); self.trailManager = new TrailManager(trailLayer); @@ -551,14 +572,39 @@ let MapAPRSMap = { // Handle new packets from LiveView self.handleEvent("new_packet", (data: MarkerData) => { - // Check if there's already a marker for this callsign - const incomingCallsign = data.callsign_group || data.callsign || data.id; + try { + // Skip if context is lost + if (!self || !self.map || self.isDestroyed) { + console.warn("Map context not ready or destroyed, skipping new packet"); + return; + } + + // Check if map exists and has the hasLayer method + if (!self.map.hasLayer) { + console.warn("Map hasLayer method not available, skipping new packet"); + return; + } + + // If heat layer is visible, we're in heat map mode - skip individual markers + if (self.heatLayer && self.map.hasLayer(self.heatLayer)) { + console.log("In heat map mode, skipping individual marker for new packet"); + return; + } + + // Check if there's already a marker for this callsign + const incomingCallsign = data.callsign_group || data.callsign || data.id; if (incomingCallsign) { // Find existing live markers for this callsign and convert them to historical dots const markersToConvert: string[] = []; - self.markerStates!.forEach((state, id) => { + // Ensure markerStates exists + if (!self.markerStates) { + console.warn("markerStates not initialized, skipping conversion"); + return; + } + + self.markerStates.forEach((state, id) => { const stateCallsign = state.callsign_group || state.callsign; // Only convert non-historical markers for the same callsign, but exclude the incoming packet's ID if ( @@ -572,8 +618,12 @@ let MapAPRSMap = { // Convert existing live markers to historical dots by updating their icon markersToConvert.forEach((id) => { - const existingMarker = self.markers!.get(id); - const existingState = self.markerStates!.get(id); + if (!self.markers || !self.markerStates) { + console.warn("markers or markerStates not available during conversion"); + return; + } + const existingMarker = self.markers.get(id); + const existingState = self.markerStates.get(id); if (existingMarker && existingState) { // Update the state to historical @@ -614,24 +664,28 @@ let MapAPRSMap = { popup: data.popup || self.buildPopupContent(data), openPopup: shouldOpenPopup, }); + } catch (error) { + console.error("Error in new_packet handler:", error); + } }); // Handle highlighting the latest packet (open its popup) self.currentPopupMarkerId = null; self.handleEvent("highlight_packet", (data: { id: string }) => { - if (!data.id) return; + if (!data.id || !self.markers || !self.markerStates) return; + // Close previous popup if open - if (self.currentPopupMarkerId && self.markers!.has(self.currentPopupMarkerId)) { - const prevMarker = self.markers!.get(self.currentPopupMarkerId); + if (self.currentPopupMarkerId && self.markers.has(self.currentPopupMarkerId)) { + const prevMarker = self.markers.get(self.currentPopupMarkerId); if (prevMarker && prevMarker.closePopup) prevMarker.closePopup(); } // Try to open popup directly first if marker exists - const marker = self.markers!.get(data.id); + const marker = self.markers.get(data.id); if (marker && marker.openPopup) { marker.openPopup(); } else { // Fallback: re-add marker with openPopup flag if it doesn't exist - const markerData = self.markerStates!.get(data.id); + const markerData = self.markerStates.get(data.id); if (markerData) { self.addMarker({ ...markerData, id: data.id, openPopup: true }); } @@ -795,6 +849,91 @@ let MapAPRSMap = { self.trailLayer.bringToFront(); } }); + + // Handle heat map data for low zoom levels + self.handleEvent("show_heat_map", (data: { heat_points: Array<{lat: number, lng: number, intensity: number}> }) => { + try { + console.log("Received heat map data with", data.heat_points?.length || 0, "points"); + + if (!self.map || self.isDestroyed) { + console.warn("Map not ready or destroyed, skipping heat map update"); + return; + } + + if (!L.heatLayer) { + console.error("Leaflet.heat plugin not loaded!"); + return; + } + + if (!self.heatLayer) { + console.log("Creating heat layer for the first time"); + try { + self.heatLayer = L.heatLayer([], { + radius: 25, + blur: 15, + maxZoom: 8, + gradient: { + 0.4: 'blue', + 0.65: 'lime', + 0.85: 'yellow', + 1.0: 'red' + } + }); + } catch (error) { + console.error("Failed to create heat layer:", error); + return; + } + } + + // Convert heat points to format expected by Leaflet.heat + const heatData = data.heat_points.map(point => [ + point.lat, + point.lng, + Math.min(point.intensity / 50.0, 1.0) // Normalize intensity to 0-1 range, cap at 1 + ] as [number, number, number]); + + console.log("Setting heat layer data:", heatData.length, "points"); + + // Update heat layer data + self.heatLayer.setLatLngs(heatData); + + // Show heat layer and hide marker layer + if (!self.map.hasLayer(self.heatLayer)) { + console.log("Adding heat layer to map"); + self.map.addLayer(self.heatLayer); + } + if (self.markerLayer && self.map.hasLayer(self.markerLayer)) { + console.log("Removing marker layer from map"); + self.map.removeLayer(self.markerLayer); + } + } catch (error) { + console.error("Error in show_heat_map handler:", error); + } + }); + + // Handle switching back to markers + self.handleEvent("show_markers", () => { + try { + console.log("Switching from heat map to markers"); + + if (!self.map || self.isDestroyed) { + console.warn("Map not ready or destroyed, skipping marker display"); + return; + } + + // Hide heat layer and show marker layer + if (self.heatLayer && self.map.hasLayer(self.heatLayer)) { + console.log("Removing heat layer"); + self.map.removeLayer(self.heatLayer); + } + if (self.markerLayer && !self.map.hasLayer(self.markerLayer)) { + console.log("Adding marker layer"); + self.map.addLayer(self.markerLayer); + } + } catch (error) { + console.error("Error in show_markers handler:", error); + } + }); }, sendBoundsToServer() { @@ -840,6 +979,12 @@ let MapAPRSMap = { return; } + // Ensure maps are initialized + if (!self.markers || !self.markerStates || !self.markerLayer) { + console.warn("Map data structures not initialized, skipping marker add"); + return; + } + const lat = parseFloat(data.lat.toString()); const lng = parseFloat(data.lng.toString()); @@ -850,8 +995,8 @@ let MapAPRSMap = { } // Check if marker already exists with same position and data - const existingMarker = self.markers!.get(data.id); - const existingState = self.markerStates!.get(data.id); + const existingMarker = self.markers.get(data.id); + const existingState = self.markerStates.get(data.id); if (existingMarker && existingState) { // Check if marker needs updating @@ -897,7 +1042,15 @@ let MapAPRSMap = { // Handle popup close events - check if hook is still connected marker.on("popupclose", () => { - safePushEvent(self.pushEvent, "popup_closed", {}); + // Only send event if not destroyed and pushEvent is still the original function + if (!self.isDestroyed && self.pushEvent && typeof self.pushEvent === 'function') { + try { + self.pushEvent("popup_closed", {}); + } catch (e) { + // Silently ignore errors if context is lost + console.debug("Unable to send popup_closed event:", e); + } + } }); } @@ -918,8 +1071,8 @@ let MapAPRSMap = { } // Add to map and store reference - marker.addTo(self.markerLayer!); - self.markers!.set(data.id, marker); + marker.addTo(self.markerLayer); + self.markers.set(data.id, marker); // Make sure historical markers and trails stay visible if (data.historical || data.is_most_recent_for_callsign) { @@ -929,7 +1082,7 @@ let MapAPRSMap = { } // Store marker state for optimization - self.markerStates!.set(data.id, { + self.markerStates.set(data.id, { lat: lat, lng: lng, symbol_table: data.symbol_table_id || "/", @@ -1332,6 +1485,18 @@ let MapAPRSMap = { }); } + // Clean up heat layer + if (self.heatLayer !== undefined && self.map !== undefined) { + try { + if (self.map.hasLayer(self.heatLayer)) { + self.map.removeLayer(self.heatLayer); + } + self.heatLayer = undefined; + } catch (e) { + console.debug("Error removing heat layer:", e); + } + } + if (self.markerLayer !== undefined) { self.markerLayer!.clearLayers(); } diff --git a/assets/js/types/map.d.ts b/assets/js/types/map.d.ts index dacc2d4..7ecf25b 100644 --- a/assets/js/types/map.d.ts +++ b/assets/js/types/map.d.ts @@ -10,6 +10,7 @@ export interface LiveViewHookContext { markers?: Map; markerStates?: Map; markerLayer?: LayerGroup; + heatLayer?: any; // L.heatLayer type trailManager?: any; // Import from trail_manager.ts when typed boundsTimer?: ReturnType; resizeHandler?: () => void; @@ -73,6 +74,12 @@ export interface MarkerState { callsign?: string; } +export interface HeatMapPoint { + lat: number; + lng: number; + intensity: number; +} + export interface MapEventData { bounds?: BoundsData; center?: CenterData; @@ -82,6 +89,7 @@ export interface MapEventData { lat?: number; lng?: number; markers?: MarkerData[]; + heat_points?: HeatMapPoint[]; } export interface MapState { diff --git a/lib/aprsme/packets/clustering.ex b/lib/aprsme/packets/clustering.ex new file mode 100644 index 0000000..f487d97 --- /dev/null +++ b/lib/aprsme/packets/clustering.ex @@ -0,0 +1,125 @@ +defmodule Aprsme.Packets.Clustering do + @moduledoc """ + Handles clustering of APRS packets for heat map visualization at low zoom levels. + """ + + alias Aprsme.Packet + + @doc """ + Clusters packets based on zoom level. + + Returns {:raw_packets, packets} for zoom > 8, or {:heat_map, clusters} for zoom <= 8. + + ## Parameters + - packets: List of Packet structs + - zoom: Current map zoom level (1-20) + - opts: Additional options (unused currently) + + ## Returns + - {:raw_packets, packets} when zoom > 8 + - {:heat_map, clusters} when zoom <= 8, where clusters is a list of: + %{lat: float, lng: float, intensity: integer} + """ + @spec cluster_packets([Packet.t()], integer(), map()) :: + {:raw_packets, [Packet.t()]} | {:heat_map, [map()]} + def cluster_packets(packets, zoom, _opts \\ %{}) + + def cluster_packets(packets, zoom, _opts) when zoom > 8 do + {:raw_packets, packets} + end + + def cluster_packets(packets, zoom, _opts) do + # Calculate clustering radius based on zoom level + # Lower zoom = larger radius (more clustering) + radius = calculate_cluster_radius(zoom) + + # Filter out packets without valid coordinates + valid_packets = filter_valid_coordinates(packets) + + # Perform clustering + clusters = perform_clustering(valid_packets, radius) + + {:heat_map, clusters} + end + + @doc """ + Calculates the clustering radius in degrees based on zoom level. + Lower zoom levels get larger radii for more aggressive clustering. + """ + @spec calculate_cluster_radius(integer()) :: float() + def calculate_cluster_radius(zoom) do + # More aggressive scaling for better separation at higher zooms + # Zoom 1: ~5 degrees, Zoom 5: ~0.3 degrees, Zoom 8: ~0.04 degrees + case zoom do + 1 -> 5.0 + 2 -> 2.5 + 3 -> 1.25 + 4 -> 0.625 + 5 -> 0.3125 + 6 -> 0.15625 + 7 -> 0.078125 + 8 -> 0.0390625 + _ -> 0.0390625 + end + end + + # Filter packets with valid lat/lon coordinates + defp filter_valid_coordinates(packets) do + Enum.filter(packets, fn packet -> + lat = decimal_to_float(Map.get(packet, :lat) || Map.get(packet, "lat")) + lon = decimal_to_float(Map.get(packet, :lon) || Map.get(packet, "lon")) + + not is_nil(lat) and not is_nil(lon) and + lat >= -90 and lat <= 90 and + lon >= -180 and lon <= 180 + end) + end + + # Convert Decimal to float, handling nil + defp decimal_to_float(nil), do: nil + defp decimal_to_float(%Decimal{} = d), do: Decimal.to_float(d) + defp decimal_to_float(n) when is_number(n), do: n / 1.0 + + defp decimal_to_float(s) when is_binary(s) do + case Float.parse(s) do + {f, _} -> f + :error -> nil + end + end + + defp decimal_to_float(_), do: nil + + # Perform the actual clustering using a simple grid-based approach + defp perform_clustering(packets, radius) do + packets + |> Enum.reduce(%{}, fn packet, clusters -> + lat = decimal_to_float(Map.get(packet, :lat) || Map.get(packet, "lat")) + lon = decimal_to_float(Map.get(packet, :lon) || Map.get(packet, "lon")) + + # Find cluster key by rounding to grid + cluster_lat = Float.round(lat / radius) * radius + cluster_lon = Float.round(lon / radius) * radius + cluster_key = {cluster_lat, cluster_lon} + + # Update cluster + Map.update(clusters, cluster_key, %{lat: lat, lng: lon, intensity: 1, lat_sum: lat, lon_sum: lon}, fn cluster -> + %{ + lat_sum: cluster.lat_sum + lat, + lon_sum: cluster.lon_sum + lon, + intensity: cluster.intensity + 1, + # Average position will be calculated after + lat: 0, + lng: 0 + } + end) + end) + |> Enum.map(fn {{_cluster_lat, _cluster_lon}, cluster} -> + # Calculate average position for cluster center + %{ + lat: cluster.lat_sum / cluster.intensity, + lng: cluster.lon_sum / cluster.intensity, + intensity: cluster.intensity + } + end) + end +end diff --git a/lib/aprsme_web/components/layouts/root.html.heex b/lib/aprsme_web/components/layouts/root.html.heex index cff15f3..6b7812a 100644 --- a/lib/aprsme_web/components/layouts/root.html.heex +++ b/lib/aprsme_web/components/layouts/root.html.heex @@ -40,6 +40,14 @@ data-source-map="false" > + <%!-- Load Leaflet.heat after Leaflet is loaded --%> +