add heatmap on zoom out

This commit is contained in:
Graham McIntire 2025-07-10 14:26:01 -05:00
parent d45aed256d
commit aaf92d9045
No known key found for this signature in database
6 changed files with 579 additions and 40 deletions

View file

@ -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();
}

View file

@ -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 {

View 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

View file

@ -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

View file

@ -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

View 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