defmodule AprsWeb.MapLive.CallsignView do use AprsWeb, :live_view alias Aprs.EncodingUtils alias Aprs.Packets alias AprsWeb.Endpoint alias AprsWeb.MapLive.MapHelpers alias AprsWeb.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: %{}, # Default bounds - will be updated based on packet locations map_bounds: %{ north: 49.0, south: 24.0, east: -66.0, west: -125.0 }, 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: ">" ) 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) # Schedule regular cleanup of old packets from the map Process.send_after(self(), :cleanup_old_packets, 60_000) # 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 # 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 # 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"]) } # 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() markers_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 socket else Enum.reduce(markers_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) {: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(msg, socket) do case msg do {:delayed_zoom, %{lat: lat, lng: lng}} -> socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) {:noreply, socket} :auto_start_replay -> # Auto-start replay if it hasn't been started yet and map is ready if not socket.assigns.replay_started and socket.assigns.map_ready do socket = start_historical_replay(socket) {:noreply, assign(socket, replay_started: true, replay_active: true)} else # If map isn't ready yet, try again in a bit if not socket.assigns.map_ready do Process.send_after(self(), :auto_start_replay, 1000) end {:noreply, socket} end :replay_next_packet -> handle_replay_next_packet(socket) :cleanup_old_packets -> # Clean up packets older than 1 hour from the map display handle_cleanup_old_packets(socket) # Stream packets from PubSub only if within bounds %Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} -> sanitized_packet = EncodingUtils.sanitize_packet(payload) sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now()) {lat, lng, _} = MapHelpers.get_coordinates(sanitized_packet) callsign_key = "#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}" if MapHelpers.has_position_data?(sanitized_packet) and packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and MapHelpers.within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and packet_within_time_threshold?( sanitized_packet, socket.assigns.packet_age_threshold ) do packet_data = build_packet_data(sanitized_packet) # Remove any previous marker for this callsign socket = if Map.has_key?(socket.assigns.visible_packets, callsign_key) do push_event(socket, "remove_marker", %{id: callsign_key}) else socket end visible_packets = %{callsign_key => sanitized_packet} last_known_position = if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position socket = socket |> push_event("new_packet", packet_data) |> assign( visible_packets: visible_packets, last_known_position: last_known_position ) {:noreply, socket} else # Remove marker if it exists and is now out of bounds or expired if Map.has_key?(socket.assigns.visible_packets, callsign_key) do socket = push_event(socket, "remove_marker", %{id: callsign_key}) visible_packets = %{} {:noreply, assign(socket, visible_packets: visible_packets)} else {:noreply, socket} end end _ -> # Ignore packets that don't match our callsign or don't have position data {:noreply, socket} end 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(nil, socket) do socket = assign(socket, replay_active: false, replay_paused: false, replay_timer_ref: nil, replay_index: 0 ) {:noreply, socket} end defp handle_replay_packet(packet, socket) do case build_packet_data(packet) 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"""
Loading packet history for {@callsign}...