From e7214f50648eec04bc141e3dc1a4d640e3bcce6f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 22 Jun 2025 17:11:58 -0500 Subject: [PATCH] misc fixes --- assets/js/map.ts | 137 ++++++++++++------ lib/aprs/packets.ex | 16 +- lib/aprs_web/live/map_live/callsign_view.ex | 95 +++++++----- lib/aprs_web/live/map_live/index.ex | 16 +- lib/aprs_web/live/map_live/packet_utils.ex | 33 +++-- .../live/packets_live/callsign_view.ex | 27 +--- lib/parser.ex | 24 +-- test/integration/historical_packets_test.exs | 2 +- 8 files changed, 197 insertions(+), 153 deletions(-) diff --git a/assets/js/map.ts b/assets/js/map.ts index c19dff9..9450779 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -64,6 +64,7 @@ interface MarkerState { historical?: boolean; is_most_recent_for_callsign?: boolean; callsign_group?: string; + callsign?: string; } interface MapEventData { @@ -503,69 +504,60 @@ let MapAPRSMap = { const incomingCallsign = data.callsign_group || data.callsign || data.id; if (incomingCallsign) { - // Find existing marker for this callsign - check both live and historical markers - let existingMarkerId: string | null = null; - let existingCallsign: string | null = null; + // Find existing live markers for this callsign and convert them to historical dots + const markersToConvert: string[] = []; self.markerStates!.forEach((state, id) => { - // Extract callsign from various sources - const stateCallsign = - state.callsign_group || - (typeof id === "string" && !id.startsWith("hist_") ? id : null) || - (typeof id === "string" && id.startsWith("hist_") - ? id.replace(/^hist_/, "").replace(/_\d+$/, "") - : null); - - // Match callsigns (only consider non-historical markers for conversion) - if (stateCallsign === incomingCallsign && !state.historical) { - existingMarkerId = id; - existingCallsign = stateCallsign; + const stateCallsign = state.callsign_group || state.callsign; + // Only convert non-historical markers for the same callsign, but exclude the incoming packet's ID + if ( + stateCallsign === incomingCallsign && + !state.historical && + String(id) !== String(data.id) + ) { + markersToConvert.push(String(id)); } }); - // If we found an existing live marker, convert it to a historical dot - if (existingMarkerId && existingCallsign) { - const existingMarker = self.markers!.get(existingMarkerId); - const existingState = self.markerStates!.get(existingMarkerId); + // Convert existing live markers to historical dots by updating their icon + markersToConvert.forEach((id) => { + const existingMarker = self.markers!.get(id); + const existingState = self.markerStates!.get(id); if (existingMarker && existingState) { - // Create historical dot data from existing marker - const historicalData = { - id: `hist_${existingCallsign}_${Date.now()}`, + // Update the state to historical + existingState.historical = true; + existingState.is_most_recent_for_callsign = false; + + // Create new historical dot icon + const historicalIconData = { + id: String(id), lat: existingState.lat, lng: existingState.lng, - callsign: existingCallsign, - callsign_group: existingCallsign, + callsign: existingState.callsign || incomingCallsign, + callsign_group: existingState.callsign_group || incomingCallsign, symbol_table_id: existingState.symbol_table, symbol_code: existingState.symbol_code, historical: true, is_most_recent_for_callsign: false, - timestamp: data.timestamp - ? typeof data.timestamp === "string" - ? new Date(data.timestamp).getTime() - 1000 - : data.timestamp - 1000 - : Date.now() - 1000, popup: existingState.popup, }; - // Remove the existing marker (this will also clean up its trail reference) - self.removeMarker(existingMarkerId); + // Update the marker's icon to show as a dot + existingMarker.setIcon(self.createMarkerIcon(historicalIconData)); - // Add the historical dot marker - self.addMarker({ - ...historicalData, - popup: self.buildPopupContent(historicalData), - }); + // Mark as historical + (existingMarker as any)._isHistorical = true; } - } + }); } - // Add the new marker with proper callsign grouping + // Add the new marker as the most recent for this callsign self.addMarker({ ...data, historical: false, is_most_recent_for_callsign: true, - callsign_group: data.callsign_group || data.callsign || incomingCallsign, // Ensure callsign_group is set for trail grouping + callsign_group: data.callsign_group || data.callsign || incomingCallsign, popup: self.buildPopupContent(data), openPopup: true, }); @@ -655,14 +647,46 @@ let MapAPRSMap = { // Handle clearing historical packets self.handleEvent("clear_historical_packets", () => { - // Remove all historical markers + // Remove only historical markers (preserve live markers and their trails) const markersToRemove: string[] = []; self.markers!.forEach((marker: any, id: any) => { - if ((marker as any)._isHistorical) { + const markerState = self.markerStates!.get(String(id)); + // Only remove markers that are explicitly historical + if ((marker as any)._isHistorical || (markerState && markerState.historical)) { markersToRemove.push(String(id)); } }); - markersToRemove.forEach((id) => self.removeMarker(id)); + + // Remove historical markers without affecting trails of live markers + markersToRemove.forEach((id) => { + const marker = self.markers!.get(id); + const markerState = self.markerStates!.get(id); + + if (marker) { + // Remove from map and storage + self.markerLayer!.removeLayer(marker); + self.markers!.delete(id); + self.markerStates!.delete(id); + + // Only remove trail if this was the only marker for this callsign + if (self.trailManager && markerState) { + const callsign = markerState.callsign_group || markerState.callsign || id; + // Check if there are any live markers left for this callsign + let hasLiveMarkers = false; + self.markerStates!.forEach((state) => { + const stateCallsign = state.callsign_group || state.callsign; + if (stateCallsign === callsign && !state.historical) { + hasLiveMarkers = true; + } + }); + + // Only remove trail if no live markers remain + if (!hasLiveMarkers) { + self.trailManager.removeTrail(callsign); + } + } + } + }); }); // Handle bounds-based marker filtering @@ -681,6 +705,11 @@ let MapAPRSMap = { self.handleEvent("clear_and_reload_markers", () => { // This event is just a trigger - the server will handle clearing and adding markers }); + + // Handle clear_all_markers event + self.handleEvent("clear_all_markers", () => { + self.clearAllMarkers(); + }); }, sendBoundsToServer() { @@ -763,8 +792,8 @@ let MapAPRSMap = { } } - // Remove existing marker if it exists - self.removeMarker(data.id); + // Remove existing marker if it exists (without affecting trails since we're just replacing it) + self.removeMarkerWithoutTrail(data.id); // Create marker - use simple dot for older historical positions, APRS icon for most recent const marker = L.marker([lat, lng], { @@ -934,8 +963,24 @@ let MapAPRSMap = { } }); - // Remove out-of-bounds markers - markersToRemove.forEach((id) => self.removeMarker(String(id))); + // Remove out-of-bounds markers without affecting trails + markersToRemove.forEach((id) => self.removeMarkerWithoutTrail(String(id))); + }, + + removeMarkerWithoutTrail(id: string | any) { + const self = this as unknown as LiveViewHookContext; + // Ensure id is a string + const markerId = typeof id === "string" ? id : String(id); + + const marker = self.markers!.get(markerId); + + if (marker) { + // Remove marker from map but don't touch trails + self.markerLayer!.removeLayer(marker); + self.markers!.delete(markerId); + self.markerStates!.delete(markerId); + // Note: We intentionally don't remove trails here + } }, createMarkerIcon(data: MarkerData): L.DivIcon { diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index d88e611..73746c5 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -394,20 +394,8 @@ defmodule Aprs.Packets do defp filter_by_region(query, _), do: query defp filter_by_callsign(query, %{callsign: callsign}) do - # Support both exact match and partial match - # For exact match, check if callsign contains a hyphen (has SSID) - if String.contains?(callsign, "-") do - # Exact match for callsign with SSID - [base_call, ssid] = String.split(callsign, "-", parts: 2) - - from p in query, - where: - ilike(p.base_callsign, ^base_call) and - ((is_nil(p.ssid) and ^ssid == "0") or p.ssid == ^ssid) - else - # Match base callsign exactly, regardless of SSID - from p in query, where: ilike(p.base_callsign, ^callsign) - end + # Use sender field for exact callsign matching + from p in query, where: ilike(p.sender, ^callsign) end defp filter_by_callsign(query, _), do: query diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index 9a5ccd4..b715b16 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -26,13 +26,8 @@ defmodule AprsWeb.MapLive.CallsignView do page_title: "APRS Map - #{normalized_callsign}", # Track visible packets by callsign visible_packets: %{}, - # Default bounds - will be updated based on packet locations - map_bounds: %{ - north: 49.0, - south: 24.0, - east: -66.0, - west: -125.0 - }, + # Map bounds - will be set when first bounds update is received + map_bounds: nil, map_center: @default_center, map_zoom: @default_zoom, default_center: @default_center, @@ -62,7 +57,9 @@ defmodule AprsWeb.MapLive.CallsignView do packets_loaded: false, # Latest symbol table ID and code latest_symbol_table_id: "/", - latest_symbol_code: ">" + latest_symbol_code: ">", + # Flag to track if latest marker was already pushed + latest_marker_pushed: false ) if connected?(socket) do @@ -164,14 +161,7 @@ defmodule AprsWeb.MapLive.CallsignView do |> assign(packets_loaded: true) end - # Auto-start replay if it hasn't been started yet - socket = - if socket.assigns.replay_started do - socket - else - socket = start_historical_replay(socket) - assign(socket, replay_started: true, replay_active: true) - end + # Don't start historical replay yet - wait for bounds to be available # If we have a pending geolocation, zoom to it after a delay socket = @@ -208,21 +198,32 @@ defmodule AprsWeb.MapLive.CallsignView do |> Enum.filter(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end) |> Map.new() - markers_to_remove = + packets_to_remove = socket.assigns.visible_packets |> Enum.reject(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end) |> Enum.map(fn {k, _} -> k end) socket = - if markers_to_remove == [] do + if packets_to_remove == [] do socket else - Enum.reduce(markers_to_remove, socket, fn k, acc -> + Enum.reduce(packets_to_remove, socket, fn k, acc -> push_event(acc, "remove_marker", %{id: k}) end) end socket = assign(socket, map_bounds: normalized_bounds, visible_packets: new_visible_packets) + + # Start historical replay now that we have bounds (if not already started) + socket = + if socket.assigns.map_ready and not socket.assigns.replay_started and + not is_nil(socket.assigns.map_bounds) do + socket = start_historical_replay(socket) + assign(socket, replay_started: true, replay_active: true) + else + socket + end + {:noreply, socket} end @@ -249,11 +250,12 @@ defmodule AprsWeb.MapLive.CallsignView do end def handle_info(:auto_start_replay, socket) do - if not socket.assigns.replay_started and socket.assigns.map_ready do + if not socket.assigns.replay_started and socket.assigns.map_ready and + not is_nil(socket.assigns.map_bounds) do socket = start_historical_replay(socket) {:noreply, assign(socket, replay_started: true, replay_active: true)} else - if not socket.assigns.map_ready do + if not socket.assigns.map_ready or is_nil(socket.assigns.map_bounds) do Process.send_after(self(), :auto_start_replay, 1000) end @@ -266,7 +268,9 @@ defmodule AprsWeb.MapLive.CallsignView do def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket) do # Only process packets for the specific callsign being viewed - if PacketUtils.generate_callsign(packet) == socket.assigns.callsign do + packet_sender = Map.get(packet, :sender, "") + + if String.upcase(packet_sender) == String.upcase(socket.assigns.callsign) do handle_info_postgres_packet(packet, socket) else {:noreply, socket} @@ -280,11 +284,12 @@ defmodule AprsWeb.MapLive.CallsignView do updated_visible_packets = Map.put(socket.assigns.visible_packets, key, packet) socket = assign(socket, visible_packets: updated_visible_packets) - packet_data = PacketUtils.build_packet_data(packet) + # Live packets are always the most recent for their callsign + packet_data = PacketUtils.build_packet_data(packet, true) socket = if packet_data, - do: push_event(socket, "add_markers", %{markers: [packet_data]}), + do: push_event(socket, "new_packet", packet_data), else: socket {:noreply, socket} @@ -672,7 +677,8 @@ defmodule AprsWeb.MapLive.CallsignView do fetch_historical_packets_for_callsign( socket.assigns.callsign, one_hour_ago, - now + now, + socket.assigns.map_bounds ) # Always fetch the latest packet with a position, regardless of age @@ -752,23 +758,23 @@ defmodule AprsWeb.MapLive.CallsignView do historical_callsigns = MapSet.new(unique_position_packets, &PacketUtils.generate_callsign/1) - socket = + {socket, latest_marker_pushed} = if latest_packet && !MapSet.member?(historical_callsigns, latest_packet_id) do # Push the latest marker as a regular marker (not historical) - packet_data = PacketUtils.build_packet_data(latest_packet) + packet_data = PacketUtils.build_packet_data(latest_packet, true) if packet_data do - push_event(socket, "add_markers", %{markers: [packet_data]}) + {push_event(socket, "new_packet", packet_data), true} else - socket + {socket, false} end else - socket + {socket, false} end if Enum.empty?(packet_data_list) do # No historical packets found (but latest marker may have been pushed above) - socket + assign(socket, latest_marker_pushed: latest_marker_pushed) else # Clear any previous historical packets from the map socket = push_event(socket, "clear_historical_packets", %{}) @@ -787,18 +793,28 @@ defmodule AprsWeb.MapLive.CallsignView do replay_packets: [], replay_index: 0, replay_start_time: one_hour_ago, - replay_end_time: now + replay_end_time: now, + latest_marker_pushed: latest_marker_pushed ) end end - defp fetch_historical_packets_for_callsign(callsign, start_time, end_time) do - Packets.get_packets_for_replay(%{ + defp fetch_historical_packets_for_callsign(callsign, start_time, end_time, bounds) do + params = %{ callsign: callsign, start_time: start_time, end_time: end_time, limit: 1000 - }) + } + + params = + if bounds do + Map.put(params, :bounds, [bounds.west, bounds.south, bounds.east, bounds.north]) + else + params + end + + Packets.get_packets_for_replay(params) end # Filter packets to only include those with unique positions (lat/lon changed) @@ -886,7 +902,12 @@ defmodule AprsWeb.MapLive.CallsignView do defp maybe_push_latest_marker(socket, nil), do: socket defp maybe_push_latest_marker(socket, packet) do - packet_data = PacketUtils.build_packet_data(packet) - if packet_data, do: push_event(socket, "add_markers", %{markers: [packet_data]}), else: socket + # Only push if we haven't already pushed it during historical replay + if Map.get(socket.assigns, :latest_marker_pushed, false) do + socket + else + packet_data = PacketUtils.build_packet_data(packet, true) + if packet_data, do: push_event(socket, "new_packet", packet_data), else: socket + end end end diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 9f8237f..9776309 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -361,10 +361,10 @@ defmodule AprsWeb.MapLive.Index do end) end - # Clear existing historical packets - socket = push_event(socket, "clear_historical_packets", %{}) + # Remove only out-of-bounds historical packets instead of clearing all + socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds}) - # Load historical packets for the new bounds + # Load additional historical packets for the new bounds if needed socket = load_historical_packets_for_bounds(socket, map_bounds) # Update map bounds and visible packets @@ -496,11 +496,13 @@ defmodule AprsWeb.MapLive.Index do else: System.unique_integer([:positive]) new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet) - marker_data = PacketUtils.build_packet_data(packet) + + # Live packets are always the most recent for their callsign + marker_data = PacketUtils.build_packet_data(packet, true) socket = if marker_data do - push_event(socket, "add_markers", %{markers: [marker_data]}) + push_event(socket, "new_packet", marker_data) else socket end @@ -965,8 +967,8 @@ defmodule AprsWeb.MapLive.Index do # Clear existing historical packets socket = assign(socket, historical_packets: %{}) - # Clear all markers on the client - socket = push_event(socket, "clear_all_markers", %{}) + # Clear only historical markers on the client (preserve live markers) + socket = push_event(socket, "clear_historical_packets", %{}) # Load historical packets with new time range socket = load_historical_packets_for_bounds(socket, socket.assigns.map_bounds) diff --git a/lib/aprs_web/live/map_live/packet_utils.ex b/lib/aprs_web/live/map_live/packet_utils.ex index 5cff344..84569e7 100644 --- a/lib/aprs_web/live/map_live/packet_utils.ex +++ b/lib/aprs_web/live/map_live/packet_utils.ex @@ -81,9 +81,9 @@ defmodule AprsWeb.MapLive.PacketUtils do @spec generate_callsign(map()) :: String.t() def generate_callsign(packet) do base_callsign = get_packet_field(packet, :base_callsign, "") - ssid = get_packet_field(packet, :ssid, 0) + ssid = get_packet_field(packet, :ssid, nil) - if ssid != 0 and ssid != "" and ssid != nil do + if ssid != nil and ssid != "" do "#{base_callsign}-#{ssid}" else base_callsign @@ -138,15 +138,20 @@ defmodule AprsWeb.MapLive.PacketUtils do Map.get(data_extended, to_string(key)) || "N/A" end - def build_packet_data(packet) do + def build_packet_data(packet, is_most_recent_for_callsign) when is_boolean(is_most_recent_for_callsign) do {lat, lon, data_extended} = MapHelpers.get_coordinates(packet) callsign = generate_callsign(packet) if lat && lon && callsign != "" && callsign != nil do - build_packet_map(packet, lat, lon, data_extended) + packet_data = build_packet_map(packet, lat, lon, data_extended) + Map.put(packet_data, "is_most_recent_for_callsign", is_most_recent_for_callsign) end end + def build_packet_data(packet) do + build_packet_data(packet, false) + end + defp build_packet_map(packet, lat, lon, data_extended) do data_extended = data_extended || %{} packet_info = extract_packet_info(packet, data_extended) @@ -157,7 +162,7 @@ defmodule AprsWeb.MapLive.PacketUtils do defp extract_packet_info(packet, data_extended) do %{ - callsign: generate_callsign(packet), + callsign: get_packet_field(packet, :sender, ""), symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"), symbol_code: get_packet_field(packet, :symbol_code, ">"), timestamp: get_timestamp(packet), @@ -197,7 +202,7 @@ defmodule AprsWeb.MapLive.PacketUtils do """
- + #{comment_html}
#{Float.round(to_float(lat), 4)}, #{Float.round(to_float(lon), 4)}
#{timestamp_html} @@ -205,7 +210,7 @@ defmodule AprsWeb.MapLive.PacketUtils do """ end - defp build_weather_popup_html(packet, callsign) do + defp build_weather_popup_html(packet, sender) do received_at = get_received_at(packet) timestamp_dt = TimeHelpers.to_datetime(received_at) @@ -222,7 +227,7 @@ defmodule AprsWeb.MapLive.PacketUtils do end """ - #{callsign} - Weather Report + #{sender} - Weather Report #{timestamp_html}
Temperature: #{get_weather_field(packet, :temperature)}°F
@@ -236,11 +241,19 @@ defmodule AprsWeb.MapLive.PacketUtils do end defp build_packet_result(packet, packet_info, lat, lon, popup) do + # Generate unique ID for live packets to prevent conflicts + packet_id = + if Map.has_key?(packet, :id) do + "live_#{packet.id}_#{System.unique_integer([:positive])}" + else + "live_#{packet_info.callsign}_#{System.unique_integer([:positive])}" + end + %{ - "id" => packet_info.callsign, + "id" => packet_id, "callsign" => packet_info.callsign, "base_callsign" => get_packet_field(packet, :base_callsign, ""), - "ssid" => get_packet_field(packet, :ssid, 0), + "ssid" => get_packet_field(packet, :ssid, nil), "lat" => to_float(lat), "lng" => to_float(lon), "data_type" => to_string(get_packet_field(packet, :data_type, "unknown")), diff --git a/lib/aprs_web/live/packets_live/callsign_view.ex b/lib/aprs_web/live/packets_live/callsign_view.ex index 9150f18..a8c49b9 100644 --- a/lib/aprs_web/live/packets_live/callsign_view.ex +++ b/lib/aprs_web/live/packets_live/callsign_view.ex @@ -95,20 +95,8 @@ defmodule AprsWeb.PacketsLive.CallsignView do order_by: [desc: p.received_at], limit: ^limit - # Apply callsign filter - filtered_query = - if String.contains?(callsign, "-") do - # Exact match for callsign with SSID - [base_call, ssid] = String.split(callsign, "-", parts: 2) - - from p in query, - where: - ilike(p.base_callsign, ^base_call) and - ((is_nil(p.ssid) and ^ssid == "0") or p.ssid == ^ssid) - else - # Match base callsign exactly, regardless of SSID - from p in query, where: ilike(p.base_callsign, ^callsign) - end + # Apply callsign filter using sender field for exact matching + filtered_query = from p in query, where: ilike(p.sender, ^callsign) filtered_query |> Repo.all() @@ -122,20 +110,15 @@ defmodule AprsWeb.PacketsLive.CallsignView do end defp packet_matches_callsign?(packet, target_callsign) do - # Check if packet sender or base_callsign matches the target callsign - # Supports both exact matches and SSID variants (e.g., "N0CALL" matches "N0CALL-1") + # Check exact match for sender field only sender = packet.sender || "" - base_callsign = packet.base_callsign || "" # Convert to uppercase for case-insensitive comparison sender_upper = String.upcase(sender) - base_upper = String.upcase(base_callsign) target_upper = String.upcase(target_callsign) - # Check exact match for sender or base_callsign - # Also check if the sender starts with the target callsign (for SSID variants) - sender_upper == target_upper || base_upper == target_upper || - String.starts_with?(sender_upper, target_upper <> "-") + # Exact match only + sender_upper == target_upper end # Helper to get all packets (stored + live) in chronological order diff --git a/lib/parser.ex b/lib/parser.ex index 4c121b1..5a56c92 100644 --- a/lib/parser.ex +++ b/lib/parser.ex @@ -74,19 +74,16 @@ defmodule Parser do ssid = case List.last(callsign_parts) do nil -> - 0 + nil s when is_binary(s) -> - case Integer.parse(s) do - {i, _} -> i - :error -> 0 - end + s i when is_integer(i) -> - i + to_string(i) _ -> - 0 + nil end {:ok, @@ -1182,17 +1179,12 @@ defmodule Parser do end end - defp extract_ssid(nil), do: 0 + defp extract_ssid(nil), do: nil - defp extract_ssid(s) when is_binary(s) do - case Integer.parse(s) do - {i, _} -> i - :error -> 0 - end - end + defp extract_ssid(s) when is_binary(s), do: s - defp extract_ssid(i) when is_integer(i), do: i - defp extract_ssid(_), do: 0 + defp extract_ssid(i) when is_integer(i), do: to_string(i) + defp extract_ssid(_), do: nil defp split_path_for_tunnel(path) do split_path(path) diff --git a/test/integration/historical_packets_test.exs b/test/integration/historical_packets_test.exs index 844debb..d682402 100644 --- a/test/integration/historical_packets_test.exs +++ b/test/integration/historical_packets_test.exs @@ -136,7 +136,7 @@ defmodule Aprs.Integration.HistoricalPacketsTest do test "only loads packets within map bounds", %{conn: conn, packets: mock_packets} do # Mock to return only packets within the specified bounds # TEST2 is at lat: 38.8, lon: -97.5, which is outside these bounds - test1_packets = Enum.filter(mock_packets, fn packet -> packet.base_callsign == "TEST1" end) + test1_packets = Enum.filter(mock_packets, fn packet -> packet.sender == "TEST1" end) expect_packets_for_replay_with_bounds(test1_packets) {:ok, view, _html} = live(conn, "/")