Merge branch 'feature/callsign-trail-line'

This commit is contained in:
Graham McIntire 2026-03-01 15:01:14 -06:00
commit fc3284f079
No known key found for this signature in database
5 changed files with 401 additions and 5 deletions

View file

@ -1520,6 +1520,12 @@ let MapAPRSMap = {
// Handle clear_all_markers event
self.handleEvent("clear_all_markers", () => {
// Clear trail line if present
if (self.trailLine) {
self.map?.removeLayer(self.trailLine);
self.trailLine = undefined;
}
// Modified to preserve historical trails - only removes non-historical markers
self.clearAllMarkers();
@ -1537,6 +1543,12 @@ let MapAPRSMap = {
return;
}
// Clear trail line if present
if (self.trailLine) {
self.map.removeLayer(self.trailLine);
self.trailLine = undefined;
}
if (!L.heatLayer) {
console.error("Leaflet.heat plugin not loaded!");
return;
@ -1595,6 +1607,12 @@ let MapAPRSMap = {
return;
}
// Clear trail line if present
if (self.trailLine) {
self.map.removeLayer(self.trailLine);
self.trailLine = undefined;
}
// Hide heat layer and show marker layer
if (self.heatLayer && self.map.hasLayer(self.heatLayer)) {
self.map.removeLayer(self.heatLayer);
@ -1606,6 +1624,69 @@ let MapAPRSMap = {
console.error("Error in show_markers handler:", error);
}
});
// Handle trail line display for tracked callsigns at low zoom
self.handleEvent("show_trail_line", (payload: BaseEventPayload) => {
const data = payload as { callsign: string; points: Array<{ lat: number; lng: number; timestamp: string }> };
if (!self.map) {
console.warn("Map not initialized, cannot show trail line");
return;
}
// Clear existing trail line if present
if (self.trailLine) {
self.map.removeLayer(self.trailLine);
self.trailLine = undefined;
}
// Clear heat map if present (switching from heat map to trail line)
if (self.heatLayer) {
self.map.removeLayer(self.heatLayer);
self.heatLayer = undefined;
}
// If we have less than 2 points, don't draw a line
if (data.points.length < 2) {
console.debug(`Not enough points for trail line: ${data.points.length}`);
return;
}
// Extract coordinates for polyline
const latlngs = data.points.map(point => [point.lat, point.lng] as [number, number]);
// Access Leaflet from window
const L = (window as any).L;
// Create polyline
self.trailLine = L.polyline(latlngs, {
color: '#3B82F6', // blue-500
weight: 3,
opacity: 0.8,
lineCap: 'round',
lineJoin: 'round',
interactive: true
});
// Add to map
self.trailLine.addTo(self.map);
console.debug(`Trail line added for ${data.callsign} with ${data.points.length} points`);
});
// Handle clearing trail line
self.handleEvent("clear_trail_line", (_payload: BaseEventPayload) => {
if (!self.map) {
return;
}
// Remove trail line if present
if (self.trailLine) {
self.map.removeLayer(self.trailLine);
self.trailLine = undefined;
console.debug("Trail line cleared");
}
});
},
sendBoundsToServer() {

View file

@ -34,6 +34,7 @@ export interface LiveViewHookContext {
zoomEndHandler?: () => void;
rfPathLines?: Array<Polyline | Marker>; // Array of Leaflet polylines and markers for RF path visualization
trailLayer?: LayerGroup;
trailLine?: Polyline; // Polyline for callsign trail display at low zoom
markerClusterGroup?: MarkerClusterGroup;
cleanupTimeouts?: ReturnType<typeof setTimeout>[];
pendingMarkers?: MarkerData[];

View file

@ -14,14 +14,22 @@ defmodule AprsmeWeb.MapLive.DisplayManager do
@doc """
Handle zoom threshold crossing between heat map and marker modes.
When tracking a callsign at low zoom, show trail line instead of heat map.
"""
@spec handle_zoom_threshold_crossing(Socket.t(), integer()) :: Socket.t()
def handle_zoom_threshold_crossing(socket, zoom) do
tracked_callsign = socket.assigns.tracked_callsign || ""
if zoom <= @heat_map_max_zoom do
# Switching to heat map
socket
|> LiveView.push_event("clear_all_markers", %{})
|> send_heat_map_for_current_bounds()
# Switching to low zoom mode
socket = LiveView.push_event(socket, "clear_all_markers", %{})
# If tracking a callsign, show trail line; otherwise show heat map
if tracked_callsign == "" do
send_heat_map_for_current_bounds(socket)
else
send_trail_line_for_tracked_callsign(socket)
end
else
# Switching to markers
trigger_marker_display(socket)
@ -74,6 +82,56 @@ defmodule AprsmeWeb.MapLive.DisplayManager do
end
end
@doc """
Send trail line data for tracked callsign.
Connects all position points chronologically, regardless of distance.
"""
@spec send_trail_line_for_tracked_callsign(Socket.t()) :: Socket.t()
def send_trail_line_for_tracked_callsign(socket) do
callsign = socket.assigns.tracked_callsign
threshold = socket.assigns.packet_age_threshold
packets =
socket.assigns.visible_packets
|> Map.values()
|> Enum.filter(fn packet ->
sender = Map.get(packet, :sender) || Map.get(packet, "sender")
received_at = Map.get(packet, :received_at) || Map.get(packet, "received_at")
String.upcase(sender) == String.upcase(callsign) &&
DateTime.compare(received_at, threshold) != :lt
end)
|> Enum.sort_by(
fn packet ->
received_at = Map.get(packet, :received_at) || Map.get(packet, "received_at")
DateTime.to_unix(received_at, :microsecond)
end,
:asc
)
if Enum.empty?(packets) do
socket
else
points =
Enum.map(packets, fn packet ->
lat = Map.get(packet, :lat) || Map.get(packet, "lat")
lon = Map.get(packet, :lon) || Map.get(packet, "lon")
received_at = Map.get(packet, :received_at) || Map.get(packet, "received_at")
%{
lat: Aprsme.EncodingUtils.to_float(lat) || 0.0,
lng: Aprsme.EncodingUtils.to_float(lon) || 0.0,
timestamp: DateTime.to_iso8601(received_at)
}
end)
LiveView.push_event(socket, "show_trail_line", %{
callsign: callsign,
points: points
})
end
end
@doc """
Send heat map data for filtered packets.
"""

View file

@ -510,6 +510,7 @@ defmodule AprsmeWeb.MapLive.Index do
socket =
socket
|> assign(tracked_callsign: "", overlay_callsign: "", other_ssids: [])
|> push_event("clear_trail_line", %{})
|> update_url_with_current_state()
{:noreply, socket}
@ -534,6 +535,14 @@ defmodule AprsmeWeb.MapLive.Index do
# Trigger cleanup to remove packets that are now outside the new duration
send(self(), :cleanup_old_packets)
# If tracking a callsign at low zoom, refresh the trail line with new duration
socket =
if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do
DisplayManager.send_trail_line_for_tracked_callsign(socket)
else
socket
end
{:noreply, socket}
end
@ -1089,8 +1098,15 @@ defmodule AprsmeWeb.MapLive.Index do
defp update_display_for_batch(socket, []), do: socket
defp update_display_for_batch(socket, marker_data_list) do
tracked_callsign = socket.assigns.tracked_callsign || ""
if socket.assigns.map_zoom <= 8 do
DisplayManager.send_heat_map_for_current_bounds(socket)
# If tracking a callsign, send trail line instead of heat map
if tracked_callsign == "" do
DisplayManager.send_heat_map_for_current_bounds(socket)
else
DisplayManager.send_trail_line_for_tracked_callsign(socket)
end
else
send_marker_batch(socket, marker_data_list)
end
@ -1987,6 +2003,15 @@ defmodule AprsmeWeb.MapLive.Index do
socket = HistoricalLoader.start_progressive_historical_loading(socket)
# If tracking a callsign and at low zoom, send trail line instead of letting
# historical loader send heat map
socket =
if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do
DisplayManager.send_trail_line_for_tracked_callsign(socket)
else
socket
end
# Mark initial historical as completed if this was the initial load
if is_initial_load do
assign(socket, initial_historical_completed: true)

View file

@ -0,0 +1,231 @@
defmodule AprsmeWeb.MapLive.DisplayManagerTest do
use Aprsme.DataCase, async: true
alias AprsmeWeb.MapLive.DisplayManager
setup do
# Create a basic socket mock for testing
socket = %Phoenix.LiveView.Socket{
assigns: %{
visible_packets: %{},
tracked_callsign: nil,
packet_age_threshold: nil,
map_bounds: nil,
map_zoom: 10,
historical_packets: %{}
},
private: %{live_temp: %{}}
}
{:ok, socket: socket}
end
# Helper function to assert push events
defp assert_push_event(socket, event_name, expected_data) do
push_events = get_in(socket.private, [:live_temp, :push_events]) || []
matching_event = Enum.find(push_events, fn [name, _data] -> name == event_name end)
assert matching_event != nil, "Expected to find event '#{event_name}' in push_events"
[_name, data] = matching_event
# For partial matching, check if all expected keys are present
Enum.each(expected_data, fn {key, value} ->
assert Map.get(data, key) == value,
"Expected #{key} to be #{inspect(value)}, got #{inspect(Map.get(data, key))}"
end)
socket
end
describe "handle_zoom_threshold_crossing/2" do
test "shows trail line for tracked callsign at low zoom", %{socket: socket} do
packet1 = %{
id: "1",
sender: "W5ISP-9",
lat: 30.123,
lon: -97.456,
received_at: ~U[2024-01-01 10:00:00Z]
}
visible_packets = %{"W5ISP-9:1" => packet1}
socket =
Map.put(socket, :assigns, %{
socket.assigns
| visible_packets: visible_packets,
tracked_callsign: "W5ISP-9",
packet_age_threshold: ~U[2024-01-01 09:00:00Z],
map_zoom: 8
})
# Zoom from 9 to 8 (crossing threshold) - socket already has new zoom
result_socket = DisplayManager.handle_zoom_threshold_crossing(socket, 8)
# Should clear markers and show trail line
assert_push_event(result_socket, "clear_all_markers", %{})
assert_push_event(result_socket, "show_trail_line", %{callsign: "W5ISP-9"})
end
test "shows heat map when no tracked callsign at low zoom", %{socket: socket} do
socket =
Map.put(socket, :assigns, %{
socket.assigns
| visible_packets: %{},
tracked_callsign: "",
historical_packets: %{},
map_bounds: %{north: 90, south: -90, east: 180, west: -180},
map_zoom: 8
})
# Zoom from 9 to 8 (crossing threshold) - socket already has new zoom
result_socket = DisplayManager.handle_zoom_threshold_crossing(socket, 8)
# Should clear markers and show heat map
assert_push_event(result_socket, "clear_all_markers", %{})
assert_push_event(result_socket, "show_heat_map", %{heat_points: []})
end
test "shows markers for tracked callsign at high zoom", %{socket: socket} do
packet1 = %{
id: "1",
sender: "W5ISP-9",
lat: 30.123,
lon: -97.456,
received_at: ~U[2024-01-01 10:00:00Z]
}
visible_packets = %{"W5ISP-9:1" => packet1}
socket =
Map.put(socket, :assigns, %{
socket.assigns
| visible_packets: visible_packets,
tracked_callsign: "W5ISP-9",
packet_age_threshold: ~U[2024-01-01 09:00:00Z],
map_zoom: 9
})
# Zoom from 8 to 9 (crossing threshold) - socket already has new zoom
result_socket = DisplayManager.handle_zoom_threshold_crossing(socket, 9)
# Should show markers
assert_push_event(result_socket, "show_markers", %{})
end
end
describe "send_trail_line_for_tracked_callsign/1" do
test "pushes trail line event with sorted points", %{socket: socket} do
packet1 = %{
id: "1",
sender: "W5ISP-9",
lat: 30.123,
lon: -97.456,
received_at: ~U[2024-01-01 10:00:00Z]
}
packet2 = %{
id: "2",
sender: "W5ISP-9",
lat: 30.124,
lon: -97.457,
received_at: ~U[2024-01-01 10:05:00Z]
}
packet3 = %{
id: "3",
sender: "W5ISP-9",
lat: 30.125,
lon: -97.458,
received_at: ~U[2024-01-01 10:10:00Z]
}
visible_packets = %{"W5ISP-9:1" => packet1, "W5ISP-9:2" => packet2, "W5ISP-9:3" => packet3}
socket =
Map.put(socket, :assigns, %{
socket.assigns
| visible_packets: visible_packets,
tracked_callsign: "W5ISP-9",
packet_age_threshold: ~U[2024-01-01 09:00:00Z]
})
result_socket = DisplayManager.send_trail_line_for_tracked_callsign(socket)
push_events = get_in(result_socket.private, [:live_temp, :push_events]) || []
assert length(push_events) == 1
[[event_name, event_data]] = push_events
assert event_name == "show_trail_line"
assert event_data == %{
callsign: "W5ISP-9",
points: [
%{lat: 30.123, lng: -97.456, timestamp: "2024-01-01T10:00:00Z"},
%{lat: 30.124, lng: -97.457, timestamp: "2024-01-01T10:05:00Z"},
%{lat: 30.125, lng: -97.458, timestamp: "2024-01-01T10:10:00Z"}
]
}
end
test "filters by packet age threshold", %{socket: socket} do
packet1 = %{
id: "1",
sender: "W5ISP-9",
lat: 30.123,
lon: -97.456,
received_at: ~U[2024-01-01 09:00:00Z]
}
# Too old
packet2 = %{
id: "2",
sender: "W5ISP-9",
lat: 30.124,
lon: -97.457,
received_at: ~U[2024-01-01 10:05:00Z]
}
# Within threshold
visible_packets = %{"W5ISP-9:1" => packet1, "W5ISP-9:2" => packet2}
socket =
Map.put(socket, :assigns, %{
socket.assigns
| visible_packets: visible_packets,
tracked_callsign: "W5ISP-9",
packet_age_threshold: ~U[2024-01-01 10:00:00Z]
})
result_socket = DisplayManager.send_trail_line_for_tracked_callsign(socket)
push_events = get_in(result_socket.private, [:live_temp, :push_events]) || []
assert length(push_events) == 1
[[event_name, event_data]] = push_events
assert event_name == "show_trail_line"
assert event_data == %{
callsign: "W5ISP-9",
points: [%{lat: 30.124, lng: -97.457, timestamp: "2024-01-01T10:05:00Z"}]
}
end
test "returns socket unchanged when no packets", %{socket: socket} do
socket =
Map.put(socket, :assigns, %{
socket.assigns
| visible_packets: %{},
tracked_callsign: "W5ISP-9",
packet_age_threshold: ~U[2024-01-01 10:00:00Z]
})
result_socket = DisplayManager.send_trail_line_for_tracked_callsign(socket)
push_events = get_in(result_socket.private, [:live_temp, :push_events]) || []
assert push_events == []
end
end
end