defmodule AprsmeWeb.MapLive.CallsignView do @moduledoc false use AprsmeWeb, :live_view import Phoenix.LiveView, only: [connected?: 1, push_event: 3] alias Aprsme.Packets alias AprsmeWeb.Endpoint alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.PacketUtils @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 = String.upcase(callsign) # Calculate one hour ago for packet age filtering one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) 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 = to_float(speed) {: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 = %{ north: to_float(bounds["north"]), south: to_float(bounds["south"]), east: to_float(bounds["east"]), west: to_float(bounds["west"]) } # 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 # Helper function to convert string or float to float defp to_float(value) when is_float(value), do: value defp to_float(value) when is_integer(value), do: value * 1.0 defp to_float(value) when is_binary(value) do case Float.parse(value) do {float_val, _} -> float_val :error -> raise ArgumentError, "Invalid float string: #{value}" end 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"""
{@callsign}
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 = DateTime.add(now, -3600, :second) 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