defmodule AprsmeWeb.MapLive.Index do @moduledoc """ LiveView for displaying real-time APRS packets on a map """ use AprsmeWeb, :live_view import AprsmeWeb.MapLive.Components import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] import Phoenix.LiveView, only: [push_event: 3, put_flash: 3] alias Aprsme.Packets alias AprsmeWeb.Live.Shared.BoundsUtils alias AprsmeWeb.Live.Shared.CoordinateUtils alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils alias AprsmeWeb.MapLive.BoundsUpdater alias AprsmeWeb.MapLive.DataBuilder alias AprsmeWeb.MapLive.DisplayManager alias AprsmeWeb.MapLive.Events alias AprsmeWeb.MapLive.HistoricalLoader alias AprsmeWeb.MapLive.Navigation alias AprsmeWeb.MapLive.PacketBatcher alias AprsmeWeb.MapLive.PacketProcessor alias AprsmeWeb.MapLive.State alias AprsmeWeb.MapLive.Subscriptions alias AprsmeWeb.MapLive.UrlParams alias AprsmeWeb.TimeUtils alias Phoenix.LiveView.JS # Import the new components module alias Phoenix.Socket.Broadcast require Logger @impl true def mount(params, session, socket) do # Basic setup deployed_at = Aprsme.Release.deployed_at() one_day_ago = TimeUtils.hours_ago(24) # Parse and determine map location {map_center, map_zoom, should_skip_initial_url_update} = Navigation.determine_map_location(params, session) # Parse trail duration and historical hours from URL trail_duration = params |> Map.get("trail", "1") |> parse_trail_duration() |> to_string() historical_hours = params |> Map.get("hist", "1") |> parse_historical_hours() |> to_string() # Calculate packet age threshold based on trail duration hours = String.to_integer(trail_duration) packet_age_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) # Setup defaults with parsed values socket = State.default(socket, packet_age_threshold) socket = assign(socket, initial_historical_completed: false, trail_duration: trail_duration, historical_hours: historical_hours ) # Handle callsign tracking - check path params first, then query params tracked_callsign = case Map.get(params, "callsign") || Map.get(params, "call") do callsign when is_binary(callsign) and callsign != "" -> callsign |> String.trim() |> String.upcase() _ -> "" end {final_map_center, final_map_zoom} = Navigation.handle_callsign_tracking( tracked_callsign, map_center, map_zoom, UrlParams.has_explicit_url_params?(params) ) # Calculate initial bounds initial_bounds = State.initial_bounds(final_map_center, final_map_zoom) socket = Subscriptions.setup(socket, initial_bounds) # Store client IP for rate-limiting in event handlers socket = Events.store_client_ip(socket) # Final socket assignment {:ok, State.finalize(socket, %{ initial_bounds: initial_bounds, final_map_center: final_map_center, final_map_zoom: final_map_zoom, should_skip_initial_url_update: should_skip_initial_url_update, tracked_callsign: tracked_callsign, deployed_at: deployed_at, one_day_ago: one_day_ago })} end # Handle both bounds_changed and update_bounds events @impl true def handle_event(event, %{"bounds" => bounds}, socket) when event in ["bounds_changed", "update_bounds"] do Events.handle_bounds_changed(bounds, socket) end @impl true def handle_event("locate_me", _params, socket), do: Events.handle_locate_me(socket) @impl true def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket), do: Events.handle_set_location(lat, lng, socket) @impl true def handle_event("clear_and_reload_markers", _params, socket), do: Events.handle_clear_and_reload_markers(socket) @impl true def handle_event("map_ready", _params, socket), do: Events.handle_map_ready(socket) @impl true def handle_event("marker_clicked", _params, socket), do: Events.handle_marker_clicked(socket) @impl true def handle_event("marker_hover_start", %{"id" => id, "path" => path, "lat" => lat, "lng" => lng}, socket), do: Events.handle_marker_hover_start(id, path, lat, lng, socket) @impl true def handle_event("marker_hover_end", _params, socket), do: Events.handle_marker_hover_end(socket) @impl true def handle_event("update_callsign", %{"callsign" => callsign}, socket), do: Events.handle_update_callsign(callsign, socket) @impl true def handle_event("track_callsign", %{"callsign" => callsign}, socket), do: Events.handle_track_callsign(callsign, socket) @impl true def handle_event("clear_tracking", _params, socket), do: Events.handle_clear_tracking(socket) @impl true def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket), do: Events.handle_update_trail_duration(duration, socket) @impl true def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket), do: Events.handle_update_historical_hours(hours, socket) @impl true def handle_event("search_callsign", %{"callsign" => callsign}, socket), do: Events.handle_search_callsign(callsign, socket) @impl true def handle_event("toggle_slideover", _params, socket), do: Events.handle_toggle_slideover(socket) @impl true def handle_event("set_slideover_state", %{"open" => open}, socket), do: Events.handle_set_slideover_state(open, socket) @impl true def handle_event("geolocation_error", %{"error" => error}, socket), do: Events.handle_geolocation_error(error, socket) @impl true def handle_event("request_geolocation", _params, socket), do: Events.handle_request_geolocation(socket) @impl true def handle_event("popup_closed", _params, socket), do: Events.handle_popup_closed(socket) @impl true def handle_event("get_assigns", _params, socket), do: Events.handle_get_assigns(socket) @impl true def handle_event("update_map_state", %{"center" => center, "zoom" => zoom} = params, socket), do: Events.handle_update_map_state(center, zoom, params, socket) @impl true def handle_event( "error_boundary_triggered", %{"message" => message, "stack" => stack, "component_id" => component_id}, socket ), do: Events.handle_error_boundary_triggered(message, stack, component_id, socket) # --- Private helpers kept in Index (used by handle_info / mount / handle_params) --- @impl true def handle_params(params, _url, socket) do # Check if we should skip this update (e.g., when using IP geolocation on initial load) if Map.get(socket.assigns, :should_skip_initial_url_update, false) and not Map.get(socket.assigns, :map_ready, false) do # Skip the URL parameter update to preserve IP geolocation socket = assign(socket, should_skip_initial_url_update: false) {:noreply, socket} else # Parse new map state from URL parameters {map_center, map_zoom} = UrlParams.parse_map_params(params) # Parse trail duration and historical hours from URL trail_duration = params |> Map.get("trail", "1") |> parse_trail_duration() |> to_string() historical_hours = params |> Map.get("hist", "1") |> parse_historical_hours() |> to_string() # Update socket state socket = socket |> assign(map_center: map_center, map_zoom: map_zoom) |> assign(trail_duration: trail_duration, historical_hours: historical_hours) # Update packet age threshold based on trail duration hours = String.to_integer(trail_duration) new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) socket = assign(socket, packet_age_threshold: new_threshold) # If map is ready, update the client-side map and trail duration socket = if socket.assigns.map_ready do socket |> push_event("zoom_to_location", %{ lat: map_center.lat, lng: map_center.lng, zoom: map_zoom }) |> push_event("update_trail_duration", %{duration_hours: hours}) else socket end # Trigger cleanup and reload if settings changed if socket.assigns[:map_ready] do send(self(), :cleanup_old_packets) send(self(), :reload_historical_packets) end {:noreply, socket} end end # Parse trail duration with validation and bounds checking defp parse_trail_duration(duration), do: SharedPacketUtils.parse_trail_duration(duration) # Parse historical hours with validation defp parse_historical_hours(hours), do: SharedPacketUtils.parse_historical_hours(hours) @impl true def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, 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({:spatial_packet, packet}, socket) do # Add packet to batcher instead of processing immediately _ = if socket.assigns[:batcher_pid] do PacketBatcher.add_packet(socket.assigns.batcher_pid, packet) else handle_info_postgres_packet(packet, socket) end {:noreply, socket} end # Retained for targeted callsign topics used by non-map LiveViews and for # rolling cluster upgrades. MapLive no longer subscribes to the global topic. def handle_info({:postgres_packet, packet}, socket) do handle_info({:spatial_packet, packet}, socket) end def handle_info({:packet_batch, packets}, socket) do {socket, marker_data_list, removal_ids} = process_packet_batch(packets, socket) socket = socket |> remove_markers_if_needed(removal_ids) |> assign(:last_update_at, DateTime.utc_now()) |> update_display_for_batch(marker_data_list) {:noreply, socket} end def handle_info({:DOWN, _ref, :process, pid, _reason}, socket) when pid == socket.assigns.batcher_pid do # PacketBatcher crashed — restart it {:ok, new_pid} = PacketBatcher.start_link(self()) Process.monitor(new_pid) {:noreply, assign(socket, :batcher_pid, new_pid)} end def handle_info(:clear_rf_path, socket) do # Clear the RF path lines with debouncing {:noreply, push_event(socket, "clear_rf_path", %{})} end def handle_info( %Broadcast{topic: "deployment_events", payload: {:new_deployment, %{deployed_at: deployed_at}}}, socket ) do {:noreply, assign(socket, :deployed_at, deployed_at)} end # Phoenix.PubSub.broadcast delivers the raw payload (no %Broadcast{} wrapper) # to plain subscribers. DeploymentNotifier broadcasts via that path. def handle_info({:new_deployment, %{deployed_at: deployed_at}}, socket) do {:noreply, assign(socket, :deployed_at, deployed_at)} end def handle_info({:drain_connections, to_drain}, socket) do if :rand.uniform(100) <= to_drain * 10 do {:noreply, socket |> put_flash(:info, "Server load balancing in progress. Reconnecting...") |> push_event("reconnect", %{delay: :rand.uniform(5000)})} else {:noreply, socket} end end def handle_info({:load_rf_path_station_packets, stations}, socket) do # Load the most recent packet for each RF path station in a single batch query station_packets = Packets.get_latest_packets_for_callsigns(stations) socket = if Enum.any?(station_packets) do # Build packet data for the RF path stations packet_data_list = DataBuilder.build_packet_data_list(station_packets) # Send these packets to the frontend DisplayManager.add_markers_if_any(socket, packet_data_list) else socket end {:noreply, socket} end def handle_info({:process_pending_bounds}, socket) do if socket.assigns.pending_bounds && !socket.assigns.historical_loading do # Process the pending bounds update bounds = socket.assigns.pending_bounds socket = assign(socket, pending_bounds: nil) BoundsUpdater.handle_bounds_update(bounds, socket) else {:noreply, socket} end end def handle_info({:load_historical_batch, batch_offset}, socket) do # For backward compatibility with old messages socket = HistoricalLoader.load_historical_batch(socket, batch_offset, socket.assigns.loading_generation) {:noreply, socket} end def handle_info({:load_historical_batch, batch_offset, generation}, socket) do # Only process if generation matches current loading generation if generation == socket.assigns.loading_generation do socket = HistoricalLoader.load_historical_batch(socket, batch_offset, generation) {:noreply, socket} else # Stale request, ignore it {:noreply, socket} end end def handle_info({:historical_loading_timeout, generation}, socket) do # Only process if generation matches current loading generation and we're still loading if generation == socket.assigns.loading_generation && socket.assigns.historical_loading do Logger.warning("Historical loading timeout reached, forcing completion") socket = socket |> assign(:historical_loading, false) |> assign(:loading_batch, socket.assigns.total_batches || 1) {:noreply, socket} else # Stale timeout or already completed, ignore it {:noreply, socket} end end def handle_info({:show_error, message}, socket) do socket = put_flash(socket, :error, message) {:noreply, socket} end # Private handler functions for each message type defp prepare_markers_for_push(markers, popup_open?) do Enum.map(markers, fn data -> data = Map.delete(data, "convert_from") if popup_open?, do: Map.put(data, :openPopup, false), else: data end) end defp handle_info_process_bounds_update(map_bounds, socket) do # Check if this is a stale update (newer bounds have been scheduled) if socket.assigns[:pending_bounds] && socket.assigns.pending_bounds != map_bounds do Logger.debug("Skipping stale bounds update, newer bounds pending") {:noreply, socket} else # Clear the timer reference since we're processing this update socket = assign(socket, bounds_update_timer: nil, pending_bounds: nil) # Check if we need to process this bounds update should_process = !socket.assigns[:initial_bounds_loaded] or socket.assigns[:needs_initial_historical_load] or not BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds) Logger.debug( "handle_info_process_bounds_update - should_process: #{should_process}, initial_bounds_loaded: #{socket.assigns[:initial_bounds_loaded]}, needs_initial_historical_load: #{socket.assigns[:needs_initial_historical_load]}" ) if should_process do Logger.debug("Processing bounds update: #{inspect(map_bounds)}") socket = BoundsUpdater.process_bounds_update(map_bounds, socket) socket = assign(socket, initial_bounds_loaded: true) {:noreply, socket} else Logger.debug("Skipping bounds update - no change detected") {:noreply, socket} end end end defp handle_info_initialize_replay(socket) do if not socket.assigns.historical_loaded and socket.assigns.map_ready do # Only proceed if we have actual map bounds - don't use world bounds if socket.assigns.map_bounds do # Use progressive loading for better performance socket = HistoricalLoader.start_progressive_historical_loading(socket) socket = assign(socket, historical_loaded: true) {:noreply, socket} else # Wait a bit longer for map bounds to be available # Increase delay to give client more time to send real bounds Process.send_after(self(), :initialize_replay, 500) {:noreply, socket} end else {:noreply, socket} end 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 # Update the tracked callsign's latest packet socket = assign( socket, :tracked_callsign_latest_packet, preferred_tracked_packet(socket.assigns.tracked_callsign_latest_packet, packet) ) process_packet_for_display(packet, socket) else {:noreply, socket} end end end defp process_packet_for_display(packet, socket) do PacketProcessor.process_packet_for_display(packet, socket) end defp process_packet_for_batch(packet, socket) do if socket.assigns.tracked_callsign == "" do PacketProcessor.process_packet_for_marker_data(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 socket = assign( socket, :tracked_callsign_latest_packet, preferred_tracked_packet(socket.assigns.tracked_callsign_latest_packet, packet) ) PacketProcessor.process_packet_for_marker_data(packet, socket) else {socket, nil, nil} end end end defp process_packet_batch(packets, socket) do Enum.reduce(packets, {socket, [], []}, fn packet, {acc_socket, markers, removals} -> {new_socket, marker_data, removed_id} = process_packet_for_batch(packet, acc_socket) markers = if marker_data, do: [marker_data | markers], else: markers removals = if removed_id, do: [removed_id | removals], else: removals {new_socket, markers, removals} end) end defp remove_markers_if_needed(socket, []), do: socket defp remove_markers_if_needed(socket, removal_ids) do DisplayManager.remove_markers_batch(socket, removal_ids) end defp update_display_for_batch(socket, []), do: socket # PacketProcessor.build_marker_data returns nil when zoom <= 8, so by the time # marker_data_list is non-empty here, zoom is always > 8. Heat-map / trail-line # rendering for low-zoom packet batches happens inside the per-packet handler. defp update_display_for_batch(socket, marker_data_list), do: send_marker_batch(socket, marker_data_list) defp send_marker_batch(socket, marker_data_list) do reversed = Enum.reverse(marker_data_list) # Deduplicate by callsign_group — when multiple senders track the same # object (e.g. two stations reporting the same radiosonde), keep only # the most recent packet per group to avoid duplicate "current" markers. deduped = reversed |> Enum.reduce(%{}, fn marker, acc -> group = marker["callsign_group"] || marker["callsign"] Map.put(acc, group, marker) end) |> Map.values() # Send callsign_groups so the frontend can look up existing markers # via its callsignIndex (keyed by callsign_group, not by packet UUID) convert_to_historical = deduped |> Enum.map(fn m -> m["callsign_group"] || m["callsign"] end) |> Enum.filter(& &1) |> Enum.uniq() markers = prepare_markers_for_push(deduped, socket.assigns.station_popup_open) push_event(socket, "new_packets", %{ packets: markers, convert_to_historical: convert_to_historical }) end # Handle replaying the next historical packet @impl true def render(assigns) do ~H""" <.map_styles /> <.map_container slideover_open={@slideover_open} map_center={@map_center} map_zoom={@map_zoom} /> <.locate_button /> <.bottom_controls {assigns} /> """ end # Additional component functions that weren't extracted yet defp locate_button(assigns) do ~H""" """ end defp toggle_slideover_js do "toggle_slideover" |> JS.push() |> JS.toggle_class("slideover-open", to: "#aprs-map") |> JS.toggle_class("slideover-closed", to: "#aprs-map") |> JS.dispatch("phx:map-resize", to: "#aprs-map") end defp bottom_controls(assigns) do ~H""" <%!-- Existing bottom controls code will go here --%> <%= if @historical_loading do %>
{gettext("Loading historical data...")} <%= if @loading_batch && @total_batches do %> ({@loading_batch}/{@total_batches}) <% end %>
<% end %> <%= if @slideover_open do %>
<% end %>

APRS.me

{gettext("Real-time APRS packet tracking")}

<%= if @tracked_callsign != "" do %>
{gettext("Tracking:")} {@tracked_callsign}
<% end %>
<%= if @tracked_callsign != "" and @other_ssids != [] do %>
<%= for ssid_info <- @other_ssids do %>
<%= if ssid_info.received_at do %> {time_ago_in_words(ssid_info.received_at)} <% end %> <.link navigate={~p"/info/#{ssid_info.callsign}"} class="text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400" title={gettext("Station info")} >
<% end %>
<% end %>
{gettext("Last Update")}
<%= if @last_update_at do %>
{time_ago_in_words(@last_update_at)}
{Calendar.strftime(@last_update_at, "%H:%M UTC")}
<% else %>
--
<% end %>
{gettext("Last Deploy")}
{time_ago_in_words(@deployed_at)}
{Calendar.strftime(@deployed_at, "%H:%M UTC")}
""" end # Clean up expired markers from visible_packets and client, but do not re-query the DB defp handle_cleanup_old_packets(socket) do # Schedule next cleanup Process.send_after(self(), :cleanup_old_packets, 60_000) threshold = socket.assigns.packet_age_threshold # Partition visible_packets into kept and expired {kept, expired_keys} = Enum.reduce(socket.assigns.visible_packets, {%{}, []}, fn {key, packet}, {kept_acc, expired_acc} -> if SharedPacketUtils.packet_within_time_threshold?(packet, threshold) do {Map.put(kept_acc, key, packet), expired_acc} else {kept_acc, [key | expired_acc]} end end) socket = DisplayManager.remove_markers_batch(socket, expired_keys) {:noreply, assign(socket, :visible_packets, kept)} end defp handle_reload_historical_packets(socket) do Logger.debug( "handle_reload_historical_packets called - map_ready: #{socket.assigns.map_ready}, map_bounds: #{inspect(socket.assigns.map_bounds)}" ) if socket.assigns.map_ready and socket.assigns.map_bounds do # Clear existing historical packets socket = push_event(socket, "clear_historical_packets", %{}) # Start progressive loading using LiveView's efficient batching socket = HistoricalLoader.start_progressive_historical_loading(socket) {:noreply, socket} else Logger.debug("Skipping historical reload - conditions not met") {:noreply, socket} end end # Helper functions # Helper functions for marker operations defp preferred_tracked_packet(nil, packet), do: packet defp preferred_tracked_packet(current_packet, incoming_packet) do cond do CoordinateUtils.has_position_data?(incoming_packet) -> incoming_packet CoordinateUtils.has_position_data?(current_packet) -> current_packet newer_packet?(incoming_packet, current_packet) -> incoming_packet true -> current_packet end end defp newer_packet?(left, right) do case {packet_received_at(left), packet_received_at(right)} do {%DateTime{} = left_dt, %DateTime{} = right_dt} -> DateTime.after?(left_dt, right_dt) {%NaiveDateTime{} = left_dt, %NaiveDateTime{} = right_dt} -> NaiveDateTime.after?(left_dt, right_dt) _ -> false end end defp packet_received_at(packet) do Map.get(packet, :received_at) || Map.get(packet, "received_at") end @impl true def terminate(_reason, socket) do Subscriptions.teardown(socket) :ok end end