Merge branch 'feature/callsign-trail-line'
This commit is contained in:
commit
fc3284f079
5 changed files with 401 additions and 5 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
1
assets/js/types/map.d.ts
vendored
1
assets/js/types/map.d.ts
vendored
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
231
test/aprsme_web/live/map_live/display_manager_test.exs
Normal file
231
test/aprsme_web/live/map_live/display_manager_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue