diff --git a/assets/js/map.ts b/assets/js/map.ts index 09d2593..ec3386c 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -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() { diff --git a/assets/js/types/map.d.ts b/assets/js/types/map.d.ts index 7f2c48f..77844ca 100644 --- a/assets/js/types/map.d.ts +++ b/assets/js/types/map.d.ts @@ -34,6 +34,7 @@ export interface LiveViewHookContext { zoomEndHandler?: () => void; rfPathLines?: Array; // 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[]; pendingMarkers?: MarkerData[]; diff --git a/lib/aprsme_web/live/map_live/display_manager.ex b/lib/aprsme_web/live/map_live/display_manager.ex index 482a8b5..d150333 100644 --- a/lib/aprsme_web/live/map_live/display_manager.ex +++ b/lib/aprsme_web/live/map_live/display_manager.ex @@ -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. """ diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index dc714d6..2a27019 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -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) 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..90ba108 --- /dev/null +++ b/test/aprsme_web/live/map_live/display_manager_test.exs @@ -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