defmodule AprsmeWeb.MapLive.Index do @moduledoc """ LiveView for displaying real-time APRS packets on a map """ use AprsmeWeb, :live_view import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2] alias AprsmeWeb.Endpoint alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.PacketUtils alias Phoenix.LiveView.Socket @default_center %{lat: 39.8283, lng: -98.5795} @default_zoom 5 @impl true def mount(_params, _session, socket) do if connected?(socket) do # Subscribe to packet updates Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets") # Schedule periodic cleanup of old packets Process.send_after(self(), :cleanup_old_packets, 60_000) end # Get deployment timestamp from config (set during application startup) deployed_at = Aprsme.Release.deployed_at() one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) socket = assign_defaults(socket, one_hour_ago) socket = assign(socket, packet_buffer: [], buffer_timer: nil) socket = assign(socket, all_packets: %{}) if connected?(socket) do Endpoint.subscribe("aprs_messages") Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") # Defer IP geolocation to avoid blocking initial page load Process.send_after(self(), :start_geolocation, 2000) end {:ok, assign(socket, map_ready: false, map_bounds: nil, map_center: %{lat: 39.8283, lng: -98.5795}, map_zoom: 4, visible_packets: %{}, historical_packets: %{}, overlay_callsign: "", trail_duration: "1", historical_hours: "1", packet_age_threshold: one_hour_ago, slideover_open: true, deployed_at: deployed_at, map_page: true )} 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, historical_packets: %{}, packet_age_threshold: one_hour_ago, map_ready: false, historical_loaded: false, pending_geolocation: nil, bounds_update_timer: nil, pending_bounds: nil, initial_bounds_loaded: false, # Overlay controls overlay_callsign: "", trail_duration: "1", historical_hours: "1", # Slideover state - will be set based on screen size slideover_open: true ) 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("clear_and_reload_markers", _params, socket) do # Only filter the current visible_packets, do not re-query the database 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() locale = Map.get(socket.assigns, :locale, "en") visible_packets_list = filtered_packets |> Enum.map(fn {_callsign, packet} -> PacketUtils.build_packet_data(packet, false, locale) end) |> Enum.filter(& &1) socket = assign(socket, visible_packets: filtered_packets) 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) # Load historical packets with a small delay to ensure map is fully ready Process.send_after(self(), :reload_historical_packets, 100) # 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", _params, socket) do {:noreply, socket} end @impl true def handle_event("update_callsign", %{"callsign" => callsign}, socket) do {:noreply, assign(socket, overlay_callsign: callsign)} end @impl true def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket) do # Convert duration string to hours and calculate new threshold hours = String.to_integer(duration) new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold) # Trigger cleanup to remove packets that are now outside the new duration send(self(), :cleanup_old_packets) {:noreply, socket} end @impl true def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket) do socket = assign(socket, historical_hours: hours) # Trigger a reload of historical packets with the new time range if socket.assigns.map_ready do send(self(), :reload_historical_packets) end {:noreply, socket} end @impl true def handle_event("search_callsign", %{"callsign" => callsign}, socket) do trimmed_callsign = callsign |> String.trim() |> String.upcase() if trimmed_callsign == "" do {:noreply, socket} else # Navigate to the callsign-specific route {:noreply, push_navigate(socket, to: "/#{trimmed_callsign}")} end end @impl true def handle_event("toggle_slideover", _params, socket) do {:noreply, assign(socket, slideover_open: !socket.assigns.slideover_open)} end @impl true def handle_event("set_slideover_state", %{"open" => open}, socket) do {:noreply, assign(socket, slideover_open: open)} end @impl true def handle_event("geolocation_error", %{"error" => _error}, socket) do # Handle geolocation errors gracefully - just continue without geolocation {:noreply, socket} end @impl true def handle_event("request_geolocation", _params, socket) do # This event is handled by the JavaScript hook {:noreply, socket} end @impl true def handle_event("get_assigns", _params, socket) do send(self(), {:test_assigns, socket.assigns}) {:noreply, socket} end @impl true def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket) def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket), do: handle_info_delayed_zoom(lat, lng, socket) def handle_info({:ip_location, %{lat: lat, lng: lng}}, socket), do: handle_info_ip_location(lat, lng, socket) def handle_info(:initialize_replay, socket), do: handle_info_initialize_replay(socket) def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket) def handle_info(:reload_historical_packets, socket), do: handle_reload_historical_packets(socket) def handle_info({:postgres_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket) def handle_info(:start_geolocation, socket), do: handle_info_start_geolocation(socket) def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket), do: handle_info({:postgres_packet, packet}, socket) # Private handler functions for each message type defp handle_info_process_bounds_update(map_bounds, socket) do if !socket.assigns.initial_bounds_loaded or not compare_bounds(map_bounds, socket.assigns.map_bounds) do socket = process_bounds_update(map_bounds, socket) socket = assign(socket, initial_bounds_loaded: true) {:noreply, socket} else {:noreply, socket} end end defp handle_info_delayed_zoom(lat, lng, socket) do socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) {:noreply, socket} end defp handle_info_ip_location(lat, 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 # Schedule a delayed zoom to give the user a moment to see the map Process.send_after(self(), {:delayed_zoom, %{lat: lat_float, lng: lng_float}}, 500) # We can still optimistically set the center and zoom socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12) {:noreply, socket} end defp handle_info_initialize_replay(socket) do if not socket.assigns.historical_loaded and socket.assigns.map_ready do # Use default bounds if map_bounds is not available yet map_bounds = socket.assigns.map_bounds || %{ north: 90.0, south: -90.0, east: 180.0, west: -180.0 } socket = assign(socket, map_bounds: map_bounds) # Use optimized query for initial load socket = load_historical_packets_for_bounds_optimized(socket, map_bounds) {:noreply, socket} else {:noreply, socket} end end defp handle_info_postgres_packet(packet, socket) do {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) callsign_key = get_callsign_key(packet) # Update all_packets all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet) socket = assign(socket, all_packets: all_packets) # Handle packet visibility logic handle_packet_visibility(packet, lat, lon, callsign_key, socket) end defp get_callsign_key(packet) do if Map.has_key?(packet, "id"), do: to_string(packet["id"]), else: System.unique_integer([:positive]) end defp handle_packet_visibility(packet, lat, lon, callsign_key, socket) do cond do should_remove_marker?(lat, lon, callsign_key, socket) -> remove_marker_from_map(callsign_key, socket) should_add_marker?(lat, lon, callsign_key, socket) -> handle_valid_postgres_packet(packet, lat, lon, socket) true -> {:noreply, socket} end end defp should_remove_marker?(lat, lon, callsign_key, socket) do !is_nil(lat) and !is_nil(lon) and Map.has_key?(socket.assigns.visible_packets, callsign_key) and not MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) end defp should_add_marker?(lat, lon, callsign_key, socket) do !is_nil(lat) and !is_nil(lon) and not Map.has_key?(socket.assigns.visible_packets, callsign_key) and MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) end defp remove_marker_from_map(callsign_key, socket) do socket = push_event(socket, "remove_marker", %{id: callsign_key}) new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key) {:noreply, assign(socket, visible_packets: new_visible_packets)} end defp handle_valid_postgres_packet(packet, _lat, _lon, socket) do # Add the packet to visible_packets and push a marker immediately callsign_key = if Map.has_key?(packet, "id"), do: to_string(packet["id"]), else: System.unique_integer([:positive]) new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet) # Live packets are always the most recent for their callsign locale = Map.get(socket.assigns, :locale, "en") marker_data = PacketUtils.build_packet_data(packet, true, locale) socket = if marker_data do push_event(socket, "new_packet", marker_data) else socket end {:noreply, assign(socket, visible_packets: new_visible_packets)} end defp handle_info_start_geolocation(socket) do if geolocation_enabled?() do ip_for_geolocation = if Application.get_env(:aprsme, AprsmeWeb.Endpoint)[:code_reloader] do # For testing geolocation in dev environment, use a public IP address. # This will be geolocated to Mountain View, CA. "8.8.8.8" else extract_ip(socket) end if valid_ip_for_geolocation?(ip_for_geolocation) do start_geolocation_task(ip_for_geolocation) end end {:noreply, socket} end defp geolocation_enabled? do Application.get_env(:aprsme, :disable_aprs_connection, false) != true end defp extract_ip(socket) do 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 end defp valid_ip_for_geolocation?(ip) do ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") end defp start_geolocation_task(ip) do Task.start(fn -> try do get_ip_location(ip) rescue _error -> send(self(), {:ip_location, @default_center}) end end) end # Handle replaying the next historical packet @impl true def render(assigns) do ~H"""