add heatmap on zoom out
This commit is contained in:
parent
d45aed256d
commit
aaf92d9045
6 changed files with 579 additions and 40 deletions
199
assets/js/map.ts
199
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<string, any>();
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
8
assets/js/types/map.d.ts
vendored
8
assets/js/types/map.d.ts
vendored
|
|
@ -10,6 +10,7 @@ export interface LiveViewHookContext {
|
|||
markers?: Map<string, Marker>;
|
||||
markerStates?: Map<string, MarkerState>;
|
||||
markerLayer?: LayerGroup;
|
||||
heatLayer?: any; // L.heatLayer type
|
||||
trailManager?: any; // Import from trail_manager.ts when typed
|
||||
boundsTimer?: ReturnType<typeof setTimeout>;
|
||||
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 {
|
||||
|
|
|
|||
125
lib/aprsme/packets/clustering.ex
Normal file
125
lib/aprsme/packets/clustering.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -40,6 +40,14 @@
|
|||
data-source-map="false"
|
||||
>
|
||||
</script>
|
||||
<%!-- Load Leaflet.heat after Leaflet is loaded --%>
|
||||
<script
|
||||
defer
|
||||
phx-track-static
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"
|
||||
>
|
||||
</script>
|
||||
<script
|
||||
defer
|
||||
phx-track-static
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
||||
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2]
|
||||
|
||||
alias Aprsme.Packets.Clustering
|
||||
alias AprsmeWeb.Endpoint
|
||||
alias AprsmeWeb.MapLive.MapHelpers
|
||||
alias AprsmeWeb.MapLive.PacketUtils
|
||||
|
|
@ -254,11 +255,20 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
|
||||
socket = assign(socket, visible_packets: filtered_packets)
|
||||
|
||||
# Check zoom level to decide between heat map and markers
|
||||
socket =
|
||||
if Enum.any?(visible_packets_list) do
|
||||
push_event(socket, "add_markers", %{markers: visible_packets_list})
|
||||
if socket.assigns.map_zoom <= 8 do
|
||||
# Use heat map for low zoom levels
|
||||
send_heat_map_data(socket, filtered_packets)
|
||||
else
|
||||
socket
|
||||
# Use regular markers for high zoom levels
|
||||
if Enum.any?(visible_packets_list) do
|
||||
socket
|
||||
|> push_event("show_markers", %{})
|
||||
|> push_event("add_markers", %{markers: visible_packets_list})
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
@ -401,9 +411,28 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
|
||||
map_center = %{lat: lat, lng: lng}
|
||||
|
||||
# Check if we're crossing the heat map/marker threshold
|
||||
old_zoom = socket.assigns.map_zoom
|
||||
crossing_threshold = (old_zoom <= 8 and zoom > 8) or (old_zoom > 8 and zoom <= 8)
|
||||
|
||||
# Update socket state
|
||||
socket = assign(socket, map_center: map_center, map_zoom: zoom)
|
||||
|
||||
# If crossing threshold, trigger appropriate display mode
|
||||
socket =
|
||||
if crossing_threshold do
|
||||
if zoom <= 8 do
|
||||
# Switching to heat map
|
||||
socket = push_event(socket, "clear_all_markers", %{})
|
||||
send_heat_map_for_current_bounds(socket)
|
||||
else
|
||||
# Switching to markers
|
||||
trigger_marker_display(socket)
|
||||
end
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
# Update URL without page reload
|
||||
new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}"
|
||||
Logger.debug("Updating URL to: #{new_path}")
|
||||
|
|
@ -637,25 +666,32 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
else: System.unique_integer([:positive])
|
||||
|
||||
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
|
||||
socket = assign(socket, visible_packets: new_visible_packets)
|
||||
|
||||
# Live packets are always the most recent for their callsign
|
||||
locale = Map.get(socket.assigns, :locale, "en")
|
||||
marker_data = PacketUtils.build_packet_data(packet, true, locale)
|
||||
|
||||
# Check zoom level to decide how to display the packet
|
||||
socket =
|
||||
if marker_data do
|
||||
# Only show new packet popup if no station popup is currently open
|
||||
if socket.assigns.station_popup_open do
|
||||
# Send without opening popup to avoid interrupting user
|
||||
push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false))
|
||||
else
|
||||
push_event(socket, "new_packet", marker_data)
|
||||
end
|
||||
if socket.assigns.map_zoom <= 8 do
|
||||
# We're in heat map mode - update the heat map with all current data
|
||||
send_heat_map_for_current_bounds(socket)
|
||||
else
|
||||
socket
|
||||
# We're in marker mode - send individual marker
|
||||
locale = Map.get(socket.assigns, :locale, "en")
|
||||
marker_data = PacketUtils.build_packet_data(packet, true, locale)
|
||||
|
||||
if marker_data do
|
||||
# Only show new packet popup if no station popup is currently open
|
||||
if socket.assigns.station_popup_open do
|
||||
# Send without opening popup to avoid interrupting user
|
||||
push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false))
|
||||
else
|
||||
push_event(socket, "new_packet", marker_data)
|
||||
end
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, visible_packets: new_visible_packets)}
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp handle_info_start_geolocation(socket) do
|
||||
|
|
@ -1608,15 +1644,36 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
end
|
||||
|
||||
if Enum.any?(packet_data_list) do
|
||||
# Use LiveView's efficient push_event for incremental updates
|
||||
# Check zoom level to decide between heat map and markers
|
||||
total_batches = socket.assigns.total_batches || 4
|
||||
is_final_batch = batch_offset >= total_batches - 1
|
||||
|
||||
socket =
|
||||
push_event(socket, "add_historical_packets_batch", %{
|
||||
packets: packet_data_list,
|
||||
batch: batch_offset,
|
||||
is_final: batch_offset >= total_batches - 1
|
||||
})
|
||||
if socket.assigns.map_zoom <= 8 do
|
||||
# For heat maps, store historical packets and update heat map when all batches are loaded
|
||||
# Add packets to historical_packets assign
|
||||
new_historical =
|
||||
Enum.reduce(historical_packets, socket.assigns.historical_packets, fn packet, acc ->
|
||||
key = if Map.has_key?(packet, :id), do: to_string(packet.id), else: to_string(packet["id"])
|
||||
Map.put(acc, key, packet)
|
||||
end)
|
||||
|
||||
socket = assign(socket, historical_packets: new_historical)
|
||||
|
||||
# If this is the final batch, update the heat map
|
||||
if is_final_batch do
|
||||
send_heat_map_for_current_bounds(socket)
|
||||
else
|
||||
socket
|
||||
end
|
||||
else
|
||||
# Use LiveView's efficient push_event for incremental updates
|
||||
push_event(socket, "add_historical_packets_batch", %{
|
||||
packets: packet_data_list,
|
||||
batch: batch_offset,
|
||||
is_final: is_final_batch
|
||||
})
|
||||
end
|
||||
|
||||
# Update progress for user feedback
|
||||
socket = assign(socket, loading_batch: batch_offset + 1)
|
||||
|
|
@ -1778,6 +1835,70 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp send_heat_map_data(socket, filtered_packets) do
|
||||
# Convert map of packets to list
|
||||
packet_list = Map.values(filtered_packets)
|
||||
|
||||
# Get clustering data
|
||||
case Clustering.cluster_packets(packet_list, socket.assigns.map_zoom) do
|
||||
{:heat_map, heat_points} ->
|
||||
push_event(socket, "show_heat_map", %{heat_points: heat_points})
|
||||
|
||||
{:raw_packets, _packets} ->
|
||||
# Shouldn't happen at zoom <= 8, but handle it anyway
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp send_heat_map_for_current_bounds(socket) do
|
||||
# Get all packets within current bounds
|
||||
all_packets =
|
||||
Map.values(socket.assigns.visible_packets) ++
|
||||
Map.values(socket.assigns.historical_packets)
|
||||
|
||||
# Filter by bounds
|
||||
filtered_packets =
|
||||
all_packets
|
||||
|> Enum.filter(&within_bounds?(&1, socket.assigns.map_bounds))
|
||||
|> Enum.uniq_by(fn packet ->
|
||||
Map.get(packet, :id) || Map.get(packet, "id")
|
||||
end)
|
||||
|
||||
# Get clustering data
|
||||
case Clustering.cluster_packets(filtered_packets, socket.assigns.map_zoom) do
|
||||
{:heat_map, heat_points} ->
|
||||
push_event(socket, "show_heat_map", %{heat_points: heat_points})
|
||||
|
||||
{:raw_packets, _packets} ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp trigger_marker_display(socket) do
|
||||
# Clear heat map and show markers
|
||||
socket = push_event(socket, "show_markers", %{})
|
||||
|
||||
# Re-send all visible packets as markers
|
||||
locale = Map.get(socket.assigns, :locale, "en")
|
||||
|
||||
visible_packets_list =
|
||||
socket.assigns.visible_packets
|
||||
|> Enum.map(fn {_callsign, packet} ->
|
||||
PacketUtils.build_packet_data(packet, true, locale)
|
||||
end)
|
||||
|> Enum.filter(& &1)
|
||||
|
||||
socket =
|
||||
if Enum.any?(visible_packets_list) do
|
||||
push_event(socket, "add_markers", %{markers: visible_packets_list})
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
# Trigger historical packet reload for markers
|
||||
start_progressive_historical_loading(socket)
|
||||
end
|
||||
|
||||
@spec process_bounds_update(map(), Socket.t()) :: Socket.t()
|
||||
defp process_bounds_update(map_bounds, socket) do
|
||||
require Logger
|
||||
|
|
|
|||
112
test/aprsme/packets/clustering_test.exs
Normal file
112
test/aprsme/packets/clustering_test.exs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
defmodule Aprsme.Packets.ClusteringTest do
|
||||
use Aprsme.DataCase
|
||||
|
||||
alias Aprsme.Packet
|
||||
alias Aprsme.Packets.Clustering
|
||||
|
||||
describe "cluster_packets/3" do
|
||||
test "returns raw packets when zoom level is greater than 8" do
|
||||
packets = [
|
||||
%Packet{id: 1, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
|
||||
%Packet{id: 2, lat: Decimal.new("40.7580"), lon: Decimal.new("-73.9855")}
|
||||
]
|
||||
|
||||
result = Clustering.cluster_packets(packets, 9, %{})
|
||||
assert result == {:raw_packets, packets}
|
||||
end
|
||||
|
||||
test "handles packets with string keys" do
|
||||
packets = [
|
||||
%{"id" => "1", "lat" => 40.7128, "lon" => -74.0060},
|
||||
%{"id" => "2", "lat" => "40.7580", "lon" => "-73.9855"}
|
||||
]
|
||||
|
||||
result = Clustering.cluster_packets(packets, 7, %{})
|
||||
assert {:heat_map, clusters} = result
|
||||
assert is_list(clusters)
|
||||
assert length(clusters) > 0
|
||||
end
|
||||
|
||||
test "returns clustered data when zoom level is 8 or less" do
|
||||
packets = [
|
||||
%Packet{id: 1, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
|
||||
%Packet{id: 2, lat: Decimal.new("40.7580"), lon: Decimal.new("-73.9855")},
|
||||
%Packet{id: 3, lat: Decimal.new("51.5074"), lon: Decimal.new("-0.1278")}
|
||||
]
|
||||
|
||||
result = Clustering.cluster_packets(packets, 7, %{})
|
||||
assert {:heat_map, clusters} = result
|
||||
assert is_list(clusters)
|
||||
assert length(clusters) > 0
|
||||
|
||||
# Check cluster structure
|
||||
[first_cluster | _] = clusters
|
||||
assert Map.has_key?(first_cluster, :lat)
|
||||
assert Map.has_key?(first_cluster, :lng)
|
||||
assert Map.has_key?(first_cluster, :intensity)
|
||||
end
|
||||
|
||||
test "groups nearby packets into single cluster" do
|
||||
# Two very close packets
|
||||
packets = [
|
||||
%Packet{id: 1, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
|
||||
%Packet{id: 2, lat: Decimal.new("40.7130"), lon: Decimal.new("-74.0058")}
|
||||
]
|
||||
|
||||
result = Clustering.cluster_packets(packets, 5, %{})
|
||||
assert {:heat_map, clusters} = result
|
||||
assert length(clusters) == 1
|
||||
assert hd(clusters).intensity == 2
|
||||
end
|
||||
|
||||
test "separates distant packets into different clusters" do
|
||||
# Two far apart packets
|
||||
packets = [
|
||||
%Packet{id: 1, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
|
||||
%Packet{id: 2, lat: Decimal.new("51.5074"), lon: Decimal.new("-0.1278")}
|
||||
]
|
||||
|
||||
result = Clustering.cluster_packets(packets, 5, %{})
|
||||
assert {:heat_map, clusters} = result
|
||||
assert length(clusters) == 2
|
||||
assert Enum.all?(clusters, &(&1.intensity == 1))
|
||||
end
|
||||
|
||||
test "handles packets with nil coordinates" do
|
||||
packets = [
|
||||
%Packet{id: 1, lat: nil, lon: nil},
|
||||
%Packet{id: 2, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
|
||||
%Packet{id: 3, lat: Decimal.new("40.7130"), lon: nil}
|
||||
]
|
||||
|
||||
result = Clustering.cluster_packets(packets, 5, %{})
|
||||
assert {:heat_map, clusters} = result
|
||||
# Only one valid packet should be clustered
|
||||
assert length(clusters) == 1
|
||||
end
|
||||
|
||||
test "cluster radius decreases with zoom level" do
|
||||
# These points are ~0.014 degrees apart (about 1.5km)
|
||||
packets = [
|
||||
%Packet{id: 1, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
|
||||
%Packet{id: 2, lat: Decimal.new("40.7228"), lon: Decimal.new("-74.0160")}
|
||||
]
|
||||
|
||||
# At zoom 3, these should be one cluster (radius 1.25 degrees)
|
||||
{:heat_map, zoom3_clusters} = Clustering.cluster_packets(packets, 3, %{})
|
||||
assert length(zoom3_clusters) == 1
|
||||
|
||||
# At zoom 8, these should be two clusters (radius 0.039 degrees)
|
||||
# but our points are only 0.014 degrees apart, so they'll still cluster
|
||||
# Let's use points that are further apart
|
||||
far_packets = [
|
||||
%Packet{id: 1, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
|
||||
%Packet{id: 2, lat: Decimal.new("40.8128"), lon: Decimal.new("-74.1060")}
|
||||
]
|
||||
|
||||
# At zoom 7, these should be two clusters (0.1 degree apart > 0.078 radius)
|
||||
{:heat_map, zoom7_clusters} = Clustering.cluster_packets(far_packets, 7, %{})
|
||||
assert length(zoom7_clusters) == 2
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue