diff --git a/lib/aprsme_web/live/map_live/display_manager.ex b/lib/aprsme_web/live/map_live/display_manager.ex index 482a8b5..a78c8c7 100644 --- a/lib/aprsme_web/live/map_live/display_manager.ex +++ b/lib/aprsme_web/live/map_live/display_manager.ex @@ -74,6 +74,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. """ diff --git a/test/aprsme_web/live/map_live/display_manager_test.exs b/test/aprsme_web/live/map_live/display_manager_test.exs new file mode 100644 index 0000000..6e93267 --- /dev/null +++ b/test/aprsme_web/live/map_live/display_manager_test.exs @@ -0,0 +1,136 @@ +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 + + 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