diff --git a/lib/aprsme_web/controllers/redirect_controller.ex b/lib/aprsme_web/controllers/redirect_controller.ex
new file mode 100644
index 0000000..5c7c39f
--- /dev/null
+++ b/lib/aprsme_web/controllers/redirect_controller.ex
@@ -0,0 +1,7 @@
+defmodule AprsmeWeb.RedirectController do
+ use AprsmeWeb, :controller
+
+ def callsign_to_map(conn, %{"callsign" => callsign}) do
+ redirect(conn, to: "/?call=#{callsign}")
+ end
+end
diff --git a/lib/aprsme_web/live/locale_hook.ex b/lib/aprsme_web/live/locale_hook.ex
index ba29445..ed244c7 100644
--- a/lib/aprsme_web/live/locale_hook.ex
+++ b/lib/aprsme_web/live/locale_hook.ex
@@ -39,7 +39,6 @@ defmodule AprsmeWeb.LocaleHook do
# Only check the view module from socket private data
case socket.private[:phoenix_live_view] do
%{view: AprsmeWeb.MapLive.Index} -> true
- %{view: AprsmeWeb.MapLive.CallsignView} -> true
_ -> false
end
end
diff --git a/lib/aprsme_web/live/map_live/callsign_view.ex b/lib/aprsme_web/live/map_live/callsign_view.ex
deleted file mode 100644
index 2dc2f3f..0000000
--- a/lib/aprsme_web/live/map_live/callsign_view.ex
+++ /dev/null
@@ -1,993 +0,0 @@
-defmodule AprsmeWeb.MapLive.CallsignView do
- @moduledoc false
- use AprsmeWeb, :live_view
-
- import Phoenix.LiveView, only: [connected?: 1, push_event: 3]
-
- alias Aprsme.Callsign
- alias Aprsme.EncodingUtils
- alias Aprsme.Packets
- alias AprsmeWeb.Endpoint
- alias AprsmeWeb.MapLive.MapHelpers
- alias AprsmeWeb.MapLive.PacketUtils
- alias AprsmeWeb.TimeUtils
-
- @default_center %{lat: 39.0, lng: -98.0}
- @default_zoom 4
- @default_replay_speed 1.0
-
- @impl true
- def mount(%{"callsign" => callsign}, _session, socket) do
- # Normalize callsign to uppercase
- normalized_callsign = Callsign.normalize(callsign)
-
- # Calculate one hour ago for packet age filtering
- one_hour_ago = TimeUtils.one_hour_ago()
-
- socket =
- assign(socket,
- callsign: normalized_callsign,
- packets: [],
- page_title: "APRS Map - #{normalized_callsign}",
- # Track visible packets by callsign
- visible_packets: %{},
- # 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,
- default_zoom: @default_zoom,
- # Replay controls
- replay_active: false,
- replay_speed: @default_replay_speed,
- replay_paused: false,
- replay_packets: [],
- replay_index: 0,
- replay_timer_ref: nil,
- replay_start_time: nil,
- replay_end_time: nil,
- # Map of packet IDs to packet data for historical packets
- historical_packets: %{},
- # Timestamp for filtering out old packets
- packet_age_threshold: one_hour_ago,
- # Flag to indicate if map is ready for replay
- map_ready: false,
- # Flag to prevent multiple replay starts
- replay_started: false,
- # Pending geolocation to zoom to after map is ready
- pending_geolocation: nil,
- # Last known position for auto-zoom
- last_known_position: nil,
- # Flag to track if packets have been loaded
- packets_loaded: false,
- # Latest symbol table ID and code
- latest_symbol_table_id: "/",
- latest_symbol_code: ">",
- # Flag to track if latest marker was already pushed
- latest_marker_pushed: false,
- # Indicate this is a map page for proper styling
- map_page: true
- )
-
- if connected?(socket) do
- Endpoint.subscribe("aprs_messages")
-
- # Don't load packets here - wait for map_ready event
- # socket = load_callsign_packets(socket, normalized_callsign)
-
- # No longer need scheduled cleanup - packets are cleaned up automatically when new ones arrive
- # Auto-start replay after a short delay
- Process.send_after(self(), :auto_start_replay, 2000)
-
- {:ok, socket}
- else
- {:ok, socket}
- end
- end
-
- def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do
- handle_bounds_update(bounds, socket)
- end
-
- def handle_event("update_bounds", %{"bounds" => bounds}, socket) do
- handle_bounds_update(bounds, socket)
- end
-
- def handle_event("locate_me", _params, socket) do
- socket = push_event(socket, "request_geolocation", %{})
- {:noreply, socket}
- end
-
- def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
- lat_float =
- cond do
- is_binary(lat) -> String.to_float(lat)
- is_integer(lat) -> lat / 1.0
- true -> lat
- end
-
- lng_float =
- cond do
- is_binary(lng) -> String.to_float(lng)
- is_integer(lng) -> lng / 1.0
- true -> lng
- end
-
- socket =
- socket
- |> assign(map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
- |> push_event("zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
-
- {:noreply, socket}
- end
-
- def handle_event("pause_replay", _params, socket) do
- if socket.assigns.replay_timer_ref do
- Process.cancel_timer(socket.assigns.replay_timer_ref)
- end
-
- socket =
- assign(socket,
- replay_paused: not socket.assigns.replay_paused,
- replay_timer_ref: nil
- )
-
- # Resume replay if unpausing
- socket =
- if socket.assigns.replay_paused do
- socket
- else
- Process.send_after(self(), :replay_next_packet, 100)
- socket
- end
-
- {:noreply, socket}
- end
-
- def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do
- speed_float = EncodingUtils.to_float(speed) || 1.0
- {:noreply, assign(socket, replay_speed: speed_float)}
- end
-
- @impl true
- def handle_event("marker_clicked", _params, socket) do
- {:noreply, socket}
- end
-
- def handle_event("map_ready", _params, socket) do
- socket = assign(socket, map_ready: true)
-
- # Load callsign packets now that the map is ready
- socket =
- if socket.assigns.packets_loaded do
- socket
- else
- socket
- |> load_callsign_packets(socket.assigns.callsign)
- |> assign(packets_loaded: true)
- end
-
- # Immediately start historical replay without waiting for bounds
- # If bounds aren't available, use a default world bounds
- socket =
- if is_nil(socket.assigns.map_bounds) do
- # Use a default global bounds to ensure we get all historical points
- default_bounds = %{
- north: 90.0,
- south: -90.0,
- east: 180.0,
- west: -180.0
- }
-
- socket = assign(socket, map_bounds: default_bounds)
- socket = start_historical_replay(socket)
- assign(socket, replay_started: true, replay_active: true)
- else
- # Bounds already available, use them
- socket = start_historical_replay(socket)
- assign(socket, replay_started: true, replay_active: true)
- end
-
- # If we have a pending geolocation, zoom to it after a delay
- socket =
- if socket.assigns.pending_geolocation do
- location = socket.assigns.pending_geolocation
- Process.send_after(self(), {:zoom_to_location, location.lat, location.lng, 12}, 1500)
- socket
- else
- # If we have a last known position, zoom to it after a delay
- if socket.assigns.last_known_position do
- pos = socket.assigns.last_known_position
- Process.send_after(self(), {:zoom_to_location, pos.lat, pos.lng, 12}, 1500)
- socket
- else
- socket
- end
- end
-
- {:noreply, socket}
- end
-
- defp handle_bounds_update(bounds, socket) do
- # Convert string keys to atom keys and parse values
- normalized_bounds = MapHelpers.normalize_bounds(bounds)
-
- # Clean up old packets first
- socket = cleanup_old_packets(socket)
-
- # Remove out-of-bounds visible packets
- new_visible_packets =
- socket.assigns.visible_packets
- |> Enum.filter(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end)
- |> Map.new()
-
- 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 packets_to_remove == [] do
- socket
- else
- 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
-
- @impl true
- def handle_info({:zoom_to_location, lat, lng, zoom}, socket) do
- socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: zoom})
- {:noreply, socket}
- end
-
- def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket) do
- socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
- {:noreply, socket}
- end
-
- def handle_info(:auto_start_replay, socket) do
- # We now handle this in map_ready event, but keep this for backward compatibility
- # and in case auto_start_replay is triggered from elsewhere
- if socket.assigns.map_ready and not socket.assigns.replay_started do
- # Update socket with bounds if needed
- socket =
- if is_nil(socket.assigns.map_bounds) do
- # Use a default global bounds if needed
- default_bounds = %{
- north: 90.0,
- south: -90.0,
- east: 180.0,
- west: -180.0
- }
-
- assign(socket, map_bounds: default_bounds)
- else
- socket
- end
-
- socket = start_historical_replay(socket)
- {:noreply, assign(socket, replay_started: true, replay_active: true)}
- else
- # Only retry if map isn't ready yet
- if not socket.assigns.map_ready do
- Process.send_after(self(), :auto_start_replay, 1000)
- end
-
- {:noreply, socket}
- end
- end
-
- def handle_info(:replay_next_packet, socket), do: handle_replay_next_packet(socket)
-
- def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket) do
- # Only process packets for the specific callsign being viewed
- 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}
- end
- end
-
- def handle_info(_msg, socket), do: {:noreply, socket}
-
- defp handle_info_postgres_packet(packet, socket) do
- # Clean up old packets before adding the new one
- socket = cleanup_old_packets(socket)
-
- key = System.unique_integer([:positive])
- updated_visible_packets = Map.put(socket.assigns.visible_packets, key, packet)
- socket = assign(socket, visible_packets: updated_visible_packets)
-
- # Live packets are always the most recent for their callsign
- locale = Map.get(socket.assigns, :locale, "en")
- packet_data = PacketUtils.build_packet_data(packet, true, locale)
-
- socket =
- if packet_data,
- do: push_event(socket, "new_packet", packet_data),
- else: socket
-
- {:noreply, socket}
- end
-
- defp handle_replay_next_packet(socket) do
- if should_continue_replay?(socket) do
- packet = Enum.at(socket.assigns.replay_packets, socket.assigns.replay_index)
- handle_replay_packet(packet, socket)
- else
- {:noreply, socket}
- end
- end
-
- defp should_continue_replay?(socket) do
- socket.assigns.replay_active and
- not socket.assigns.replay_paused and
- socket.assigns.replay_index < length(socket.assigns.replay_packets)
- end
-
- defp handle_replay_packet(packet, socket) do
- locale = Map.get(socket.assigns, :locale, "en")
-
- case PacketUtils.build_packet_data(packet, false, locale) do
- nil -> handle_invalid_replay_packet(socket)
- packet_data -> handle_valid_replay_packet(packet, packet_data, socket)
- end
- end
-
- defp handle_invalid_replay_packet(socket) do
- timer_ref = Process.send_after(self(), :replay_next_packet, 10)
-
- socket =
- assign(socket,
- replay_index: socket.assigns.replay_index + 1,
- replay_timer_ref: timer_ref
- )
-
- {:noreply, socket}
- end
-
- defp handle_valid_replay_packet(packet, packet_data, socket) do
- historical_packets = Map.put(socket.assigns.historical_packets, packet_data["id"], packet)
- socket = push_event(socket, "historical_packet", Map.put(packet_data, :historical, true))
- delay = trunc(1000 / socket.assigns.replay_speed)
- timer_ref = Process.send_after(self(), :replay_next_packet, delay)
-
- socket =
- assign(socket,
- replay_index: socket.assigns.replay_index + 1,
- replay_timer_ref: timer_ref,
- historical_packets: historical_packets
- )
-
- {:noreply, socket}
- end
-
- @impl true
- def render(assigns) do
- ~H"""
-
-
-
-
-
-
-
-
-
-
-
-
0 or @replay_active, do: "hidden")
- ]}>
-
Loading Historical Data
-
- Loading packet history for {@callsign}...
-
-
-
-
-
-
- """
- end
-
- defp cleanup_old_packets(socket, age_threshold_seconds \\ 3600) do
- # Update packet age threshold
- threshold_time = DateTime.add(DateTime.utc_now(), -age_threshold_seconds, :second)
- socket = assign(socket, packet_age_threshold: threshold_time)
-
- # Remove expired packets from visible_packets
- expired_keys =
- socket.assigns.visible_packets
- |> Enum.filter(fn {_key, packet} ->
- not packet_within_time_threshold?(packet, threshold_time)
- end)
- |> Enum.map(fn {key, _} -> key end)
-
- # Only update the client if there are expired markers
- socket =
- if expired_keys == [] do
- socket
- else
- Enum.reduce(expired_keys, socket, fn key, acc ->
- push_event(acc, "remove_marker", %{id: key})
- end)
- end
-
- # Use Map.drop/2 for better performance
- updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys)
- assign(socket, visible_packets: updated_visible_packets)
- end
-
- defp packet_within_time_threshold?(packet, threshold) do
- received_at = Map.get(packet, :received_at, DateTime.utc_now())
-
- case received_at do
- %DateTime{} -> DateTime.compare(received_at, threshold) != :lt
- # If no timestamp, assume it's current
- _ -> true
- end
- end
-
- defp start_historical_replay(socket) do
- # Fetch historical packets for the specific callsign from the last hour
- now = DateTime.utc_now()
- one_hour_ago = TimeUtils.one_hour_ago()
-
- packets =
- fetch_historical_packets_for_callsign(
- socket.assigns.callsign,
- one_hour_ago,
- now,
- socket.assigns.map_bounds
- )
-
- # Always fetch the latest packet with a position, regardless of age
- latest_packet = get_latest_packet_for_callsign(socket.assigns.callsign)
-
- # Sort packets by inserted_at to identify the most recent
- sorted_packets = sort_packets_by_inserted_at(packets)
-
- # Filter out packets with unchanged positions (only keep if lat/lon changed)
- unique_position_packets = filter_unique_positions(sorted_packets)
-
- # Build packet data for all positions, marking which is the most recent
- packet_data_list = build_historical_packet_data_list(unique_position_packets, socket.assigns.callsign)
-
- # Always push the latest position as a live marker (not historical)
- {socket, latest_marker_pushed} = push_latest_marker(socket, latest_packet)
-
- if Enum.empty?(packet_data_list) do
- # No historical packets found (but latest marker may have been pushed above)
- assign(socket, latest_marker_pushed: latest_marker_pushed)
- else
- # Clear any previous historical packets from the map
- socket = push_event(socket, "clear_historical_packets", %{})
- # Send all historical packets at once (excluding the latest position)
- filtered_trail = filter_trail_excluding_latest(packet_data_list, latest_packet)
-
- socket = push_event(socket, "add_historical_packets", %{packets: filtered_trail})
- # Store historical packets in assigns for reference
- historical_packets_map = build_historical_packets_map(filtered_trail, unique_position_packets)
-
- assign(socket,
- historical_packets: historical_packets_map,
- replay_packets: [],
- replay_index: 0,
- replay_start_time: one_hour_ago,
- replay_end_time: now,
- latest_marker_pushed: latest_marker_pushed
- )
- end
- end
-
- defp get_latest_packet_for_callsign(callsign) do
- %{callsign: callsign}
- |> Packets.get_recent_packets()
- |> Enum.filter(&MapHelpers.has_position_data?/1)
- |> Enum.sort_by(& &1.received_at, {:desc, DateTime})
- |> List.first()
- end
-
- defp sort_packets_by_inserted_at(packets) do
- Enum.sort_by(
- packets,
- fn packet ->
- case packet.inserted_at do
- %NaiveDateTime{} = naive_dt ->
- DateTime.from_naive!(naive_dt, "Etc/UTC")
-
- %DateTime{} = dt ->
- dt
-
- _other ->
- DateTime.utc_now()
- end
- end,
- {:desc, DateTime}
- )
- end
-
- defp build_historical_packet_data_list(unique_position_packets, callsign) do
- unique_position_packets
- |> Enum.with_index()
- |> Enum.map(fn {packet, index} ->
- build_single_historical_packet_data(packet, index, callsign)
- end)
- |> Enum.filter(& &1)
- end
-
- defp build_single_historical_packet_data(packet, index, callsign) do
- # Note: We don't have access to locale here, so we'll use default "en"
- case PacketUtils.build_packet_data(packet, false, "en") do
- nil ->
- nil
-
- packet_data ->
- # Generate a unique ID for this historical packet
- packet_id =
- "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
-
- packet_data
- |> Map.put("id", packet_id)
- |> Map.put("is_historical", true)
- |> Map.put("is_most_recent_for_callsign", false)
- |> Map.put("callsign_group", callsign)
- |> Map.put("timestamp", get_packet_timestamp(packet))
- end
- end
-
- defp get_packet_timestamp(packet) do
- case packet.inserted_at do
- %NaiveDateTime{} = naive_dt ->
- DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
-
- %DateTime{} = dt ->
- DateTime.to_unix(dt, :millisecond)
-
- _other ->
- DateTime.to_unix(DateTime.utc_now(), :millisecond)
- end
- end
-
- defp push_latest_marker(socket, latest_packet) do
- if latest_packet do
- locale = Map.get(socket.assigns, :locale, "en")
- packet_data = PacketUtils.build_packet_data(latest_packet, true, locale)
-
- if packet_data,
- do: {push_event(socket, "new_packet", packet_data), true},
- else: {socket, false}
- else
- {socket, false}
- end
- end
-
- defp filter_trail_excluding_latest(packet_data_list, latest_packet) do
- case latest_packet do
- nil ->
- packet_data_list
-
- _ ->
- latest_latlon = latest_packet |> MapHelpers.get_coordinates() |> Tuple.to_list() |> Enum.take(2)
-
- Enum.reject(packet_data_list, fn pd ->
- pd_latlon = [pd["lat"], pd["lng"]]
-
- abs(Enum.at(pd_latlon, 0) - Enum.at(latest_latlon, 0)) < 0.00001 and
- abs(Enum.at(pd_latlon, 1) - Enum.at(latest_latlon, 1)) < 0.00001
- end)
- end
- end
-
- defp build_historical_packets_map(filtered_trail, unique_position_packets) do
- filtered_trail
- |> Enum.zip(unique_position_packets)
- |> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
- Map.put(acc, packet_data["id"], packet)
- end)
- end
-
- 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)
- @spec filter_unique_positions([struct()]) :: [struct()]
- defp filter_unique_positions(packets) do
- packets
- |> Enum.reduce([], fn packet, acc ->
- {lat, lng, _} = MapHelpers.get_coordinates(packet)
- process_packet_position(packet, acc, lat, lng)
- end)
- |> Enum.reverse()
- end
-
- defp process_packet_position(packet, acc, lat, lng) when not is_nil(lat) and not is_nil(lng) do
- check_position_uniqueness(packet, acc)
- end
-
- defp process_packet_position(_packet, acc, _lat, _lng), do: acc
-
- defp check_position_uniqueness(packet, []), do: [packet]
-
- defp check_position_uniqueness(packet, [last_packet | _] = acc) do
- if position_changed?(packet, last_packet) do
- [packet | acc]
- else
- acc
- end
- end
-
- # Check if position changed significantly between two packets (more than ~1 meter)
- @spec position_changed?(struct(), struct()) :: boolean()
- defp position_changed?(packet1, packet2) do
- {lat1, lng1, _} = MapHelpers.get_coordinates(packet1)
- {lat2, lng2, _} = MapHelpers.get_coordinates(packet2)
-
- abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001
- end
-
- defp load_callsign_packets(socket, callsign) do
- latest_packet =
- %{callsign: callsign}
- |> Packets.get_recent_packets()
- |> Enum.filter(&MapHelpers.has_position_data?/1)
- |> Enum.sort_by(& &1.received_at, {:desc, DateTime})
- |> List.first()
-
- last_known_position = extract_last_known_position(latest_packet)
- latest_symbol_table_id = extract_latest_symbol_table_id(latest_packet)
- latest_symbol_code = extract_latest_symbol_code(latest_packet)
- visible_packets = build_visible_packets(latest_packet)
- socket = maybe_push_latest_marker(socket, latest_packet)
-
- # Clean up old packets before assigning new visible packets
- socket = cleanup_old_packets(socket)
-
- assign(socket,
- last_known_position: last_known_position,
- visible_packets: visible_packets,
- latest_symbol_table_id: latest_symbol_table_id,
- latest_symbol_code: latest_symbol_code
- )
- end
-
- defp extract_last_known_position(nil), do: nil
-
- defp extract_last_known_position(packet) do
- {lat, lng, _} = MapHelpers.get_coordinates(packet)
- if lat && lng, do: %{lat: lat, lng: lng}
- end
-
- defp extract_latest_symbol_table_id(%{data_extended: %{symbol_table_id: id}}) when is_binary(id), do: id
-
- defp extract_latest_symbol_table_id(_), do: "/"
-
- defp extract_latest_symbol_code(%{data_extended: %{symbol_code: code}}) when is_binary(code), do: code
-
- defp extract_latest_symbol_code(_), do: ">"
-
- defp build_visible_packets(nil), do: %{}
-
- defp build_visible_packets(packet) do
- callsign_key =
- "#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}"
-
- %{callsign_key => packet}
- end
-
- defp maybe_push_latest_marker(socket, nil), do: socket
-
- defp maybe_push_latest_marker(socket, packet) do
- # Only push if we haven't already pushed it during historical replay
- if Map.get(socket.assigns, :latest_marker_pushed, false) do
- socket
- else
- locale = Map.get(socket.assigns, :locale, "en")
- packet_data = PacketUtils.build_packet_data(packet, true, locale)
- if packet_data, do: push_event(socket, "new_packet", packet_data), else: socket
- end
- end
-end
diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex
index 59235e5..b3c76c5 100644
--- a/lib/aprsme_web/live/map_live/index.ex
+++ b/lib/aprsme_web/live/map_live/index.ex
@@ -126,6 +126,9 @@ defmodule AprsmeWeb.MapLive.Index do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
+ # Check for callsign parameter
+ tracked_callsign = Map.get(params, "call", "")
+
{:ok,
assign(socket,
map_ready: false,
@@ -136,6 +139,7 @@ defmodule AprsmeWeb.MapLive.Index do
visible_packets: %{},
historical_packets: %{},
overlay_callsign: "",
+ tracked_callsign: tracked_callsign,
trail_duration: "1",
historical_hours: "1",
packet_age_threshold: one_hour_ago,
@@ -335,6 +339,36 @@ defmodule AprsmeWeb.MapLive.Index do
{:noreply, assign(socket, overlay_callsign: callsign)}
end
+ @impl true
+ def handle_event("track_callsign", %{"callsign" => callsign}, socket) do
+ normalized_callsign = String.upcase(String.trim(callsign))
+
+ socket =
+ if normalized_callsign == "" do
+ # Clear tracking
+ socket
+ |> assign(tracked_callsign: "")
+ |> push_patch(to: "/")
+ else
+ # Set tracking
+ socket
+ |> assign(tracked_callsign: normalized_callsign)
+ |> push_patch(to: "/?call=#{normalized_callsign}")
+ end
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("clear_tracking", _params, socket) do
+ socket =
+ socket
+ |> assign(tracked_callsign: "", overlay_callsign: "")
+ |> push_patch(to: "/")
+
+ {:noreply, socket}
+ end
+
@impl true
def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket) do
# Convert duration string to hours and calculate new threshold
@@ -614,6 +648,23 @@ defmodule AprsmeWeb.MapLive.Index do
end
defp handle_info_postgres_packet(packet, socket) do
+ # Check if we're tracking a specific callsign
+ # Only process if this packet is from the tracked callsign
+ if socket.assigns.tracked_callsign == "" do
+ # No tracking - show all packets
+ process_packet_for_display(packet, socket)
+ else
+ packet_sender = Map.get(packet, :sender, Map.get(packet, "sender", ""))
+
+ if String.upcase(packet_sender) == String.upcase(socket.assigns.tracked_callsign) do
+ process_packet_for_display(packet, socket)
+ else
+ {:noreply, socket}
+ end
+ end
+ end
+
+ defp process_packet_for_display(packet, socket) do
{lat, lon, _data_extended} = MapHelpers.get_coordinates(packet)
callsign_key = get_callsign_key(packet)
@@ -1012,21 +1063,37 @@ defmodule AprsmeWeb.MapLive.Index do
{gettext("Search Callsign")}
-
@@ -1589,12 +1656,22 @@ defmodule AprsmeWeb.MapLive.Index do
if packets_module == Aprsme.Packets do
# Use cached queries for better performance
# Include zoom level in cache key for better cache efficiency
- Aprsme.CachedQueries.get_recent_packets_cached(%{
+ params = %{
bounds: bounds,
limit: batch_size,
offset: offset,
zoom: zoom
- })
+ }
+
+ # Add callsign filter if tracking
+ params =
+ if socket.assigns.tracked_callsign == "" do
+ params
+ else
+ Map.put(params, :callsign, socket.assigns.tracked_callsign)
+ end
+
+ Aprsme.CachedQueries.get_recent_packets_cached(params)
else
# Fallback for testing
packets_module.get_recent_packets_optimized(%{
diff --git a/lib/aprsme_web/live/map_live/popup_component.ex b/lib/aprsme_web/live/map_live/popup_component.ex
index 53db3b7..cacd471 100644
--- a/lib/aprsme_web/live/map_live/popup_component.ex
+++ b/lib/aprsme_web/live/map_live/popup_component.ex
@@ -9,7 +9,7 @@ defmodule AprsmeWeb.MapLive.PopupComponent do
~H"""