diff --git a/assets/js/map.ts b/assets/js/map.ts index d96c9c4..09d2593 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -1748,6 +1748,46 @@ let MapAPRSMap = { // Remove existing marker if it exists (without affecting trails since we're just replacing it) self.removeMarkerWithoutTrail(data.id); + // When adding a "most recent" marker, convert any existing "current" markers + // for the same callsign_group to historical dots. This prevents duplicate labels + // when multiple senders report the same object (e.g. two stations tracking one radiosonde). + if (data.is_most_recent_for_callsign && self.callsignIndex) { + const csGroup = data.callsign_group || data.callsign; + if (csGroup) { + const existingIds = self.callsignIndex.get(csGroup); + if (existingIds) { + for (const eid of existingIds) { + if (String(eid) === String(data.id)) continue; + const eState = self.markerStates.get(eid); + const eMarker = self.markers.get(eid); + if ( + eState && + eMarker && + eState.is_most_recent_for_callsign + ) { + eState.historical = true; + eState.is_most_recent_for_callsign = false; + eMarker.setIcon( + self.createMarkerIcon({ + id: String(eid), + lat: eState.lat, + lng: eState.lng, + callsign: eState.callsign || csGroup, + callsign_group: eState.callsign_group || csGroup, + symbol_table_id: eState.symbol_table, + symbol_code: eState.symbol_code, + historical: true, + is_most_recent_for_callsign: false, + popup: eState.popup, + }), + ); + (eMarker as APRSMarker)._isHistorical = true; + } + } + } + } + } + // Create marker - use simple dot for older historical positions, APRS icon for most recent const marker = L.marker([lat, lng], { icon: self.createMarkerIcon(data), diff --git a/lib/aprsme_web/live/map_live/data_builder.ex b/lib/aprsme_web/live/map_live/data_builder.ex index 2ed2d1c..4458a67 100644 --- a/lib/aprsme_web/live/map_live/data_builder.ex +++ b/lib/aprsme_web/live/map_live/data_builder.ex @@ -49,6 +49,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do build_packet_data(packet, is_most_recent, locale) end) |> Enum.filter(& &1) + |> deduplicate_by_callsign_group() Process.delete(:weather_callsigns_cache) result @@ -111,6 +112,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do symbol_code = get_packet_field(packet, :symbol_code, ">") callsign = display_name(packet) + label = map_label(packet) # For historical (non-most-recent) packets, use a simple red dot HTML # instead of the full APRS symbol — matches what the JS createMarkerIcon @@ -120,19 +122,16 @@ defmodule AprsmeWeb.MapLive.DataBuilder do AprsmeWeb.SymbolRenderer.render_marker_symbol( symbol_table_id, symbol_code, - callsign, + label, 32 ) else - historical_dot_html(callsign) + historical_dot_html(label) end raw_comment = get_packet_field(packet, :comment, "") clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment) - # Use map_label for display, but keep display_name for grouping - label = map_label(packet) - %{ "id" => if(is_most_recent, do: "current_#{get_packet_id(packet)}", else: "hist_#{get_packet_id(packet)}"), "lat" => to_float(lat), @@ -229,7 +228,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do @spec build_simple_popup(map(), boolean()) :: String.t() def build_simple_popup(packet, has_weather) do # Build popup HTML directly without database queries - callsign = display_name(packet) + callsign = map_label(packet) timestamp_dt = get_packet_received_at(packet) cache_buster = System.system_time(:millisecond) @@ -685,4 +684,15 @@ defmodule AprsmeWeb.MapLive.DataBuilder do |> Enum.map(fn packet -> build_minimal_packet_data(packet, false, has_weather) end) |> Enum.filter(& &1) end + + # When multiple senders track the same object, keep only one "current" marker + # per callsign_group to avoid duplicate labels on the map. + defp deduplicate_by_callsign_group(marker_list) do + marker_list + |> Enum.reduce(%{}, fn marker, acc -> + group = marker["callsign_group"] || marker["callsign"] + Map.put(acc, group, marker) + end) + |> Map.values() + end end diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 2ae91ac..49e6be2 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -849,51 +849,13 @@ defmodule AprsmeWeb.MapLive.Index do end def handle_info({:packet_batch, packets}, socket) do - # Process batch: collect marker data and removal IDs without pushing events - {socket, marker_data_list, removal_ids} = - Enum.reduce(packets, {socket, [], []}, fn packet, {acc_socket, markers, removals} -> - {new_socket, marker_data, removed_id} = process_packet_for_batch(packet, acc_socket) - markers = if marker_data, do: [marker_data | markers], else: markers - removals = if removed_id, do: [removed_id | removals], else: removals - {new_socket, markers, removals} - end) + {socket, marker_data_list, removal_ids} = process_packet_batch(packets, socket) - # Push a single batched remove event for all removals socket = - if removal_ids == [] do - socket - else - DisplayManager.remove_markers_batch(socket, removal_ids) - end - - socket = assign(socket, :last_update_at, DateTime.utc_now()) - - # Send heat map update once if in heat map mode, otherwise batch markers - socket = - cond do - socket.assigns.map_zoom <= 8 and marker_data_list != [] -> - DisplayManager.send_heat_map_for_current_bounds(socket) - - marker_data_list != [] -> - reversed = Enum.reverse(marker_data_list) - - # Extract convert_to_historical callsign keys from markers that replaced existing ones - convert_to_historical = - reversed - |> Enum.map(&Map.get(&1, "convert_from")) - |> Enum.filter(& &1) - |> Enum.uniq() - - markers = prepare_markers_for_push(reversed, socket.assigns.station_popup_open) - - push_event(socket, "new_packets", %{ - packets: markers, - convert_to_historical: convert_to_historical - }) - - true -> - socket - end + socket + |> remove_markers_if_needed(removal_ids) + |> assign(:last_update_at, DateTime.utc_now()) + |> update_display_for_batch(marker_data_list) {:noreply, socket} end @@ -1100,6 +1062,61 @@ defmodule AprsmeWeb.MapLive.Index do end end + defp process_packet_batch(packets, socket) do + Enum.reduce(packets, {socket, [], []}, fn packet, {acc_socket, markers, removals} -> + {new_socket, marker_data, removed_id} = process_packet_for_batch(packet, acc_socket) + markers = if marker_data, do: [marker_data | markers], else: markers + removals = if removed_id, do: [removed_id | removals], else: removals + {new_socket, markers, removals} + end) + end + + defp remove_markers_if_needed(socket, []), do: socket + + defp remove_markers_if_needed(socket, removal_ids) do + DisplayManager.remove_markers_batch(socket, removal_ids) + end + + defp update_display_for_batch(socket, []), do: socket + + defp update_display_for_batch(socket, marker_data_list) do + if socket.assigns.map_zoom <= 8 do + DisplayManager.send_heat_map_for_current_bounds(socket) + else + send_marker_batch(socket, marker_data_list) + end + end + + defp send_marker_batch(socket, marker_data_list) do + reversed = Enum.reverse(marker_data_list) + + # Deduplicate by callsign_group — when multiple senders track the same + # object (e.g. two stations reporting the same radiosonde), keep only + # the most recent packet per group to avoid duplicate "current" markers. + deduped = + reversed + |> Enum.reduce(%{}, fn marker, acc -> + group = marker["callsign_group"] || marker["callsign"] + Map.put(acc, group, marker) + end) + |> Map.values() + + # Send callsign_groups so the frontend can look up existing markers + # via its callsignIndex (keyed by callsign_group, not by packet UUID) + convert_to_historical = + deduped + |> Enum.map(fn m -> m["callsign_group"] || m["callsign"] end) + |> Enum.filter(& &1) + |> Enum.uniq() + + markers = prepare_markers_for_push(deduped, socket.assigns.station_popup_open) + + push_event(socket, "new_packets", %{ + packets: markers, + convert_to_historical: convert_to_historical + }) + end + # Handle replaying the next historical packet @impl true diff --git a/test/aprsme_web/live/map_live/data_builder_test.exs b/test/aprsme_web/live/map_live/data_builder_test.exs index c79d81b..375a0ae 100644 --- a/test/aprsme_web/live/map_live/data_builder_test.exs +++ b/test/aprsme_web/live/map_live/data_builder_test.exs @@ -143,6 +143,9 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do assert result["callsign"] == "DB0SDA" # But grouping uses object name assert result["callsign_group"] == "P-K5SGD" + # Symbol HTML should show sender, not object name + assert result["symbol_html"] =~ "DB0SDA" + refute result["symbol_html"] =~ "P-K5SGD" end test "build_minimal_packet_data uses red dot HTML for historical (non-most-recent) packets" do @@ -343,5 +346,31 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do groups = Enum.map(results, & &1["callsign_group"]) assert Enum.all?(groups, &(&1 == "P-K5SGD")) end + + test "build_simple_popup shows sender for object packets" do + packet = %{ + id: Ecto.UUID.generate(), + sender: "WA0YMH-2", + base_callsign: "WA0YMH", + ssid: "2", + object_name: "X3234025", + is_object: true, + is_item: false, + lat: 33.251833, + lon: -95.719833, + data_type: "object", + received_at: DateTime.utc_now(), + symbol_table_id: "/", + symbol_code: "O", + comment: "Clb=5.9m/s t=-58.5C 404.800MHz Type=RS41-NG rdzTTGOsonde", + has_position: true, + path: "TCPIP*,qAR,WA0YMH-2" + } + + popup = DataBuilder.build_simple_popup(packet, false) + + assert popup =~ "WA0YMH-2" + refute popup =~ "X3234025" + end end end