defmodule AprsWeb.MapLive.Index do @moduledoc """ LiveView for displaying real-time APRS packets on a map """ use AprsWeb, :live_view alias Aprs.EncodingUtils alias AprsWeb.Endpoint alias AprsWeb.Helpers.AprsSymbols alias Parser.Types.MicE alias Phoenix.LiveView.Socket @default_center %{lat: 39.8283, lng: -98.5795} @default_zoom 5 @ip_api_url "https://ip-api.com/json/" @finch_name Aprs.Finch @default_replay_speed 1000 @impl true def mount(_params, _session, socket) do one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) socket = assign_defaults(socket, one_hour_ago) if connected?(socket) do Endpoint.subscribe("aprs_messages") maybe_start_geolocation(socket) schedule_timers() end {:ok, socket} end @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t() defp assign_defaults(socket, one_hour_ago) do assign(socket, packets: [], page_title: "APRS Map", visible_packets: %{}, map_bounds: %{ north: 49.0, south: 24.0, east: -66.0, west: -125.0 }, map_center: @default_center, map_zoom: @default_zoom, 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, historical_packets: %{}, packet_age_threshold: one_hour_ago, map_ready: false, replay_started: false, pending_geolocation: nil, bounds_update_timer: nil, pending_bounds: nil ) end @spec maybe_start_geolocation(Socket.t()) :: Socket.t() defp maybe_start_geolocation(socket) do if Application.get_env(:aprs, :disable_aprs_connection, false) != true do ip = case socket.private[:connect_info][:peer_data][:address] do {a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}" {a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}" _ -> nil end if ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") do Task.start(fn -> try do get_ip_location(ip) rescue _error -> send(self(), {:ip_location, @default_center}) end end) end end socket end defp schedule_timers do Process.send_after(self(), :cleanup_old_packets, 60_000) Process.send_after(self(), :initialize_replay, 2000) end @impl true def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do handle_bounds_update(bounds, socket) end @impl true def handle_event("update_bounds", %{"bounds" => bounds}, socket) do handle_bounds_update(bounds, socket) end @impl true def handle_event("locate_me", _params, socket) do # Send JavaScript command to request browser geolocation {:noreply, push_event(socket, "request_geolocation", %{})} end @impl true def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do # Update map center and zoom when location is received # Ensure coordinates are floats 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 @impl true def handle_event("toggle_replay", _params, socket) do if socket.assigns.replay_active do # Stop replay if socket.assigns.replay_timer_ref, do: Process.cancel_timer(socket.assigns.replay_timer_ref) # Clear historical packets from the map socket = socket |> push_event("clear_historical_packets", %{}) |> assign( replay_active: false, replay_timer_ref: nil, replay_paused: false, replay_packets: [], replay_index: 0, historical_packets: %{}, replay_started: false ) # Restart replay after a short delay Process.send_after(self(), :initialize_replay, 1000) {:noreply, socket} else # If not active, the user manually requested a replay restart {:noreply, start_historical_replay(socket)} end end @impl true def handle_event("pause_replay", _params, socket) do if socket.assigns.replay_active do if socket.assigns.replay_paused do # Resume replay timer_ref = Process.send_after(self(), :replay_next_packet, 1000) {:noreply, assign(socket, replay_paused: false, replay_timer_ref: timer_ref)} else # Pause replay if socket.assigns.replay_timer_ref, do: Process.cancel_timer(socket.assigns.replay_timer_ref) {:noreply, assign(socket, replay_paused: true, replay_timer_ref: nil)} end else {:noreply, socket} end end @impl true 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("clear_and_reload_markers", _params, socket) do # Clear all markers on the client first socket = push_event(socket, "clear_markers", %{}) # Filter visible packets to only include those within current bounds filtered_packets = socket.assigns.visible_packets |> Enum.filter(fn {_callsign, packet} -> within_bounds?(packet, socket.assigns.map_bounds) && packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold) end) |> Map.new() # Convert filtered packets to packet data for the map visible_packets_list = filtered_packets |> Enum.map(fn {_callsign, packet} -> build_packet_data(packet) end) # Remove any nil packet data |> Enum.filter(& &1) # Update the state to only include the filtered packets socket = assign(socket, visible_packets: filtered_packets) # Add the visible markers back to the map socket = if Enum.any?(visible_packets_list) do push_event(socket, "add_markers", %{markers: visible_packets_list}) else socket end {:noreply, socket} end @impl true def handle_event("map_ready", _params, socket) do socket = assign(socket, map_ready: true) # If we have pending geolocation, zoom to it now socket = if socket.assigns.pending_geolocation do %{lat: lat, lng: lng} = socket.assigns.pending_geolocation push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) else socket end {:noreply, socket} end @impl true def handle_event("marker_clicked", %{"id" => _id, "callsign" => _callsign, "lat" => _lat, "lng" => _lng}, socket) do {:noreply, socket} end @spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()} defp handle_bounds_update(bounds, socket) do # Update the map bounds from the client map_bounds = %{ north: bounds["north"], south: bounds["south"], east: bounds["east"], west: bounds["west"] } # Validate bounds to prevent invalid coordinates if map_bounds.north > 90 or map_bounds.south < -90 or map_bounds.north <= map_bounds.south do # Invalid bounds, skip update {:noreply, socket} else # Cancel any pending bounds update timer if socket.assigns[:bounds_update_timer] do Process.cancel_timer(socket.assigns.bounds_update_timer) end # Set a debounced update timer to prevent excessive processing timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 250) socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds) {:noreply, socket} end end @spec process_bounds_update(map(), Socket.t()) :: Socket.t() defp process_bounds_update(map_bounds, socket) do # Filter visible packets to only include those within the new bounds and time threshold new_visible_packets = socket.assigns.visible_packets |> Enum.filter(fn {_callsign, packet} -> within_bounds?(packet, map_bounds) && packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold) end) |> Map.new() # Get packets that are no longer visible (to remove from map) packets_to_remove = socket.assigns.visible_packets |> Enum.reject(fn {_callsign, packet} -> within_bounds?(packet, map_bounds) end) |> Enum.map(fn {callsign, _packet} -> callsign end) # Clear markers that are outside the new bounds socket = if Enum.any?(packets_to_remove) do # Remove markers that are outside bounds socket = Enum.reduce(packets_to_remove, socket, fn callsign, acc_socket -> push_event(acc_socket, "remove_marker", %{id: callsign}) end) # Also send a general filter event to clean up any remaining out-of-bounds markers push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds}) else socket end # If replay is not active, update the replay packets based on the new bounds socket = if socket.assigns.replay_active do socket else # Clear any existing replay data when bounds change significantly socket = assign(socket, replay_packets: [], replay_index: 0, historical_packets: %{}, map_ready: true ) socket end assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets, bounds_update_timer: nil, pending_bounds: nil ) end @impl true def handle_info(msg, socket) do case msg do {:process_bounds_update, map_bounds} -> # Process the debounced bounds update socket = process_bounds_update(map_bounds, socket) {:noreply, socket} {:delayed_zoom, %{lat: lat, lng: lng}} -> socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) {:noreply, socket} {:ip_location, %{lat: lat, lng: lng}} -> # Ensure we're using numeric values for coordinates 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 # Update map center first socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12) # If map is ready, zoom to location immediately, otherwise store for later socket = if socket.assigns.map_ready do push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12}) else assign(socket, pending_geolocation: %{lat: lat_float, lng: lng_float}) end {:noreply, socket} :initialize_replay -> # Only start replay if it hasn't been started yet if not socket.assigns.replay_started and socket.assigns.map_ready do socket = start_historical_replay(socket) {:noreply, assign(socket, replay_started: true)} else {: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) %{event: "packet", payload: payload} -> # Sanitize the packet to prevent encoding errors sanitized_packet = EncodingUtils.sanitize_packet(payload) # Add received timestamp if not present sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now()) # Only process packets with position data that are within current map bounds # AND are not older than 1 hour if has_position_data?(sanitized_packet) && within_bounds?(sanitized_packet, socket.assigns.map_bounds) && packet_within_time_threshold?(sanitized_packet, socket.assigns.packet_age_threshold) do # Convert to a simple map structure for JSON encoding packet_data = build_packet_data(sanitized_packet) # Only push if we have valid packet data if packet_data do # Generate a unique key for this packet callsign_key = "#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}" # Update visible packets tracking visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet) # Push the packet to the client-side JavaScript socket = socket |> push_event("new_packet", packet_data) |> assign(visible_packets: visible_packets) {:noreply, socket} else # Invalid packet data, skip it {:noreply, socket} end else # Ignore packets without position data, outside bounds, or too old {:noreply, socket} end end end # Handle replaying the next historical packet defp handle_replay_next_packet(socket) do %{ replay_packets: packets, replay_index: index, replay_speed: speed, historical_packets: historical_packets } = socket.assigns if index < length(packets) do # Get the next packet and advance the index packet = Enum.at(packets, index) next_index = index + 1 # Convert to a simple map structure for JSON encoding packet_data = build_packet_data(packet) # Only process packets with valid position data socket = if packet_data do # Add is_historical flag and timestamp packet_data = Map.merge(packet_data, %{ "is_historical" => true, "timestamp" => if(Map.has_key?(packet, :received_at), do: DateTime.to_iso8601(packet.received_at) ) }) # Generate a unique key for this packet packet_id = "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}" # Update historical packets tracking new_historical_packets = Map.put(historical_packets, packet_id, packet) # Send packet data to client socket |> push_event("historical_packet", packet_data) |> assign( replay_index: next_index, historical_packets: new_historical_packets ) else # Skip packets without position data, just advance the index assign(socket, replay_index: next_index) end # Calculate delay for next packet (faster based on replay speed) delay_ms = trunc(1000 / speed) # Schedule the next packet timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms) {:noreply, assign(socket, replay_timer_ref: timer_ref)} else # All packets replayed, start over with a short delay Process.send_after(self(), :initialize_replay, 10_000) # Set active to false but don't clear packets - will auto-restart socket = assign(socket, replay_active: false, replay_timer_ref: nil, replay_started: false ) {:noreply, socket} end end @impl true def render(assigns) do ~H"""