125 lines
3.8 KiB
Elixir
125 lines
3.8 KiB
Elixir
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
|