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';
|
import type { Map as LeafletMap, Marker, TileLayer, LayerGroup, DivIcon, LatLngBounds } from 'leaflet';
|
||||||
|
|
||||||
// Declare Leaflet as a global variable with proper typing
|
// 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;
|
declare const OverlappingMarkerSpiderfier: any;
|
||||||
|
|
||||||
// Import trail management functionality
|
// Import trail management functionality
|
||||||
|
|
@ -204,6 +206,25 @@ let MapAPRSMap = {
|
||||||
self.markers = new Map<string, any>();
|
self.markers = new Map<string, any>();
|
||||||
self.markerLayer = L.layerGroup().addTo(self.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
|
// Create trail layer and manager
|
||||||
const trailLayer = L.layerGroup().addTo(self.map);
|
const trailLayer = L.layerGroup().addTo(self.map);
|
||||||
self.trailManager = new TrailManager(trailLayer);
|
self.trailManager = new TrailManager(trailLayer);
|
||||||
|
|
@ -551,14 +572,39 @@ let MapAPRSMap = {
|
||||||
|
|
||||||
// Handle new packets from LiveView
|
// Handle new packets from LiveView
|
||||||
self.handleEvent("new_packet", (data: MarkerData) => {
|
self.handleEvent("new_packet", (data: MarkerData) => {
|
||||||
// Check if there's already a marker for this callsign
|
try {
|
||||||
const incomingCallsign = data.callsign_group || data.callsign || data.id;
|
// 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) {
|
if (incomingCallsign) {
|
||||||
// Find existing live markers for this callsign and convert them to historical dots
|
// Find existing live markers for this callsign and convert them to historical dots
|
||||||
const markersToConvert: string[] = [];
|
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;
|
const stateCallsign = state.callsign_group || state.callsign;
|
||||||
// Only convert non-historical markers for the same callsign, but exclude the incoming packet's ID
|
// Only convert non-historical markers for the same callsign, but exclude the incoming packet's ID
|
||||||
if (
|
if (
|
||||||
|
|
@ -572,8 +618,12 @@ let MapAPRSMap = {
|
||||||
|
|
||||||
// Convert existing live markers to historical dots by updating their icon
|
// Convert existing live markers to historical dots by updating their icon
|
||||||
markersToConvert.forEach((id) => {
|
markersToConvert.forEach((id) => {
|
||||||
const existingMarker = self.markers!.get(id);
|
if (!self.markers || !self.markerStates) {
|
||||||
const existingState = self.markerStates!.get(id);
|
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) {
|
if (existingMarker && existingState) {
|
||||||
// Update the state to historical
|
// Update the state to historical
|
||||||
|
|
@ -614,24 +664,28 @@ let MapAPRSMap = {
|
||||||
popup: data.popup || self.buildPopupContent(data),
|
popup: data.popup || self.buildPopupContent(data),
|
||||||
openPopup: shouldOpenPopup,
|
openPopup: shouldOpenPopup,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in new_packet handler:", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle highlighting the latest packet (open its popup)
|
// Handle highlighting the latest packet (open its popup)
|
||||||
self.currentPopupMarkerId = null;
|
self.currentPopupMarkerId = null;
|
||||||
self.handleEvent("highlight_packet", (data: { id: string }) => {
|
self.handleEvent("highlight_packet", (data: { id: string }) => {
|
||||||
if (!data.id) return;
|
if (!data.id || !self.markers || !self.markerStates) return;
|
||||||
|
|
||||||
// Close previous popup if open
|
// Close previous popup if open
|
||||||
if (self.currentPopupMarkerId && self.markers!.has(self.currentPopupMarkerId)) {
|
if (self.currentPopupMarkerId && self.markers.has(self.currentPopupMarkerId)) {
|
||||||
const prevMarker = self.markers!.get(self.currentPopupMarkerId);
|
const prevMarker = self.markers.get(self.currentPopupMarkerId);
|
||||||
if (prevMarker && prevMarker.closePopup) prevMarker.closePopup();
|
if (prevMarker && prevMarker.closePopup) prevMarker.closePopup();
|
||||||
}
|
}
|
||||||
// Try to open popup directly first if marker exists
|
// 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) {
|
if (marker && marker.openPopup) {
|
||||||
marker.openPopup();
|
marker.openPopup();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: re-add marker with openPopup flag if it doesn't exist
|
// 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) {
|
if (markerData) {
|
||||||
self.addMarker({ ...markerData, id: data.id, openPopup: true });
|
self.addMarker({ ...markerData, id: data.id, openPopup: true });
|
||||||
}
|
}
|
||||||
|
|
@ -795,6 +849,91 @@ let MapAPRSMap = {
|
||||||
self.trailLayer.bringToFront();
|
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() {
|
sendBoundsToServer() {
|
||||||
|
|
@ -840,6 +979,12 @@ let MapAPRSMap = {
|
||||||
return;
|
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 lat = parseFloat(data.lat.toString());
|
||||||
const lng = parseFloat(data.lng.toString());
|
const lng = parseFloat(data.lng.toString());
|
||||||
|
|
||||||
|
|
@ -850,8 +995,8 @@ let MapAPRSMap = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if marker already exists with same position and data
|
// Check if marker already exists with same position and data
|
||||||
const existingMarker = self.markers!.get(data.id);
|
const existingMarker = self.markers.get(data.id);
|
||||||
const existingState = self.markerStates!.get(data.id);
|
const existingState = self.markerStates.get(data.id);
|
||||||
|
|
||||||
if (existingMarker && existingState) {
|
if (existingMarker && existingState) {
|
||||||
// Check if marker needs updating
|
// Check if marker needs updating
|
||||||
|
|
@ -897,7 +1042,15 @@ let MapAPRSMap = {
|
||||||
|
|
||||||
// Handle popup close events - check if hook is still connected
|
// Handle popup close events - check if hook is still connected
|
||||||
marker.on("popupclose", () => {
|
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
|
// Add to map and store reference
|
||||||
marker.addTo(self.markerLayer!);
|
marker.addTo(self.markerLayer);
|
||||||
self.markers!.set(data.id, marker);
|
self.markers.set(data.id, marker);
|
||||||
|
|
||||||
// Make sure historical markers and trails stay visible
|
// Make sure historical markers and trails stay visible
|
||||||
if (data.historical || data.is_most_recent_for_callsign) {
|
if (data.historical || data.is_most_recent_for_callsign) {
|
||||||
|
|
@ -929,7 +1082,7 @@ let MapAPRSMap = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store marker state for optimization
|
// Store marker state for optimization
|
||||||
self.markerStates!.set(data.id, {
|
self.markerStates.set(data.id, {
|
||||||
lat: lat,
|
lat: lat,
|
||||||
lng: lng,
|
lng: lng,
|
||||||
symbol_table: data.symbol_table_id || "/",
|
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) {
|
if (self.markerLayer !== undefined) {
|
||||||
self.markerLayer!.clearLayers();
|
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>;
|
markers?: Map<string, Marker>;
|
||||||
markerStates?: Map<string, MarkerState>;
|
markerStates?: Map<string, MarkerState>;
|
||||||
markerLayer?: LayerGroup;
|
markerLayer?: LayerGroup;
|
||||||
|
heatLayer?: any; // L.heatLayer type
|
||||||
trailManager?: any; // Import from trail_manager.ts when typed
|
trailManager?: any; // Import from trail_manager.ts when typed
|
||||||
boundsTimer?: ReturnType<typeof setTimeout>;
|
boundsTimer?: ReturnType<typeof setTimeout>;
|
||||||
resizeHandler?: () => void;
|
resizeHandler?: () => void;
|
||||||
|
|
@ -73,6 +74,12 @@ export interface MarkerState {
|
||||||
callsign?: string;
|
callsign?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HeatMapPoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
intensity: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MapEventData {
|
export interface MapEventData {
|
||||||
bounds?: BoundsData;
|
bounds?: BoundsData;
|
||||||
center?: CenterData;
|
center?: CenterData;
|
||||||
|
|
@ -82,6 +89,7 @@ export interface MapEventData {
|
||||||
lat?: number;
|
lat?: number;
|
||||||
lng?: number;
|
lng?: number;
|
||||||
markers?: MarkerData[];
|
markers?: MarkerData[];
|
||||||
|
heat_points?: HeatMapPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapState {
|
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"
|
data-source-map="false"
|
||||||
>
|
>
|
||||||
</script>
|
</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
|
<script
|
||||||
defer
|
defer
|
||||||
phx-track-static
|
phx-track-static
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
||||||
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2]
|
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2]
|
||||||
|
|
||||||
|
alias Aprsme.Packets.Clustering
|
||||||
alias AprsmeWeb.Endpoint
|
alias AprsmeWeb.Endpoint
|
||||||
alias AprsmeWeb.MapLive.MapHelpers
|
alias AprsmeWeb.MapLive.MapHelpers
|
||||||
alias AprsmeWeb.MapLive.PacketUtils
|
alias AprsmeWeb.MapLive.PacketUtils
|
||||||
|
|
@ -254,11 +255,20 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
|
|
||||||
socket = assign(socket, visible_packets: filtered_packets)
|
socket = assign(socket, visible_packets: filtered_packets)
|
||||||
|
|
||||||
|
# Check zoom level to decide between heat map and markers
|
||||||
socket =
|
socket =
|
||||||
if Enum.any?(visible_packets_list) do
|
if socket.assigns.map_zoom <= 8 do
|
||||||
push_event(socket, "add_markers", %{markers: visible_packets_list})
|
# Use heat map for low zoom levels
|
||||||
|
send_heat_map_data(socket, filtered_packets)
|
||||||
else
|
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
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -401,9 +411,28 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
|
|
||||||
map_center = %{lat: lat, lng: lng}
|
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
|
# Update socket state
|
||||||
socket = assign(socket, map_center: map_center, map_zoom: zoom)
|
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
|
# Update URL without page reload
|
||||||
new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}"
|
new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}"
|
||||||
Logger.debug("Updating URL to: #{new_path}")
|
Logger.debug("Updating URL to: #{new_path}")
|
||||||
|
|
@ -637,25 +666,32 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
else: System.unique_integer([:positive])
|
else: System.unique_integer([:positive])
|
||||||
|
|
||||||
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
|
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
|
# Check zoom level to decide how to display the packet
|
||||||
locale = Map.get(socket.assigns, :locale, "en")
|
|
||||||
marker_data = PacketUtils.build_packet_data(packet, true, locale)
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
if marker_data do
|
if socket.assigns.map_zoom <= 8 do
|
||||||
# Only show new packet popup if no station popup is currently open
|
# We're in heat map mode - update the heat map with all current data
|
||||||
if socket.assigns.station_popup_open do
|
send_heat_map_for_current_bounds(socket)
|
||||||
# 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
|
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
|
end
|
||||||
|
|
||||||
{:noreply, assign(socket, visible_packets: new_visible_packets)}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_info_start_geolocation(socket) do
|
defp handle_info_start_geolocation(socket) do
|
||||||
|
|
@ -1608,15 +1644,36 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
if Enum.any?(packet_data_list) do
|
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
|
total_batches = socket.assigns.total_batches || 4
|
||||||
|
is_final_batch = batch_offset >= total_batches - 1
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
push_event(socket, "add_historical_packets_batch", %{
|
if socket.assigns.map_zoom <= 8 do
|
||||||
packets: packet_data_list,
|
# For heat maps, store historical packets and update heat map when all batches are loaded
|
||||||
batch: batch_offset,
|
# Add packets to historical_packets assign
|
||||||
is_final: batch_offset >= total_batches - 1
|
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
|
# Update progress for user feedback
|
||||||
socket = assign(socket, loading_batch: batch_offset + 1)
|
socket = assign(socket, loading_batch: batch_offset + 1)
|
||||||
|
|
@ -1778,6 +1835,70 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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()
|
@spec process_bounds_update(map(), Socket.t()) :: Socket.t()
|
||||||
defp process_bounds_update(map_bounds, socket) do
|
defp process_bounds_update(map_bounds, socket) do
|
||||||
require Logger
|
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