defmodule AprsmeWeb.MapLive.Index do @moduledoc """ LiveView for displaying real-time APRS packets on a map """ use AprsmeWeb, :live_view import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1] import AprsmeWeb.MapLive.Components import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] import Phoenix.LiveView, only: [connected?: 1, get_connect_params: 1, push_event: 3, push_patch: 2, put_flash: 3] alias Aprsme.Packets alias Aprsme.Packets.Clustering alias AprsmeWeb.Live.Shared.BoundsUtils alias AprsmeWeb.Live.Shared.CoordinateUtils alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils alias AprsmeWeb.Live.Shared.ParamUtils alias AprsmeWeb.MapLive.DataBuilder alias AprsmeWeb.MapLive.DisplayManager alias AprsmeWeb.MapLive.HistoricalLoader alias AprsmeWeb.MapLive.Navigation alias AprsmeWeb.MapLive.PacketBatcher alias AprsmeWeb.MapLive.PacketProcessor alias AprsmeWeb.MapLive.RfPath alias AprsmeWeb.MapLive.UrlParams alias AprsmeWeb.TimeUtils alias Phoenix.LiveView.JS # Import the new components module alias Phoenix.LiveView.Socket alias Phoenix.Socket.Broadcast @impl true def mount(params, session, socket) do socket = setup_subscriptions(socket) # Basic setup deployed_at = Aprsme.Release.deployed_at() one_hour_ago = TimeUtils.one_day_ago() # 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 = assign_defaults(socket, packet_age_threshold) socket = assign(socket, initial_historical_completed: false, trail_duration: trail_duration, historical_hours: historical_hours ) # Setup additional subscriptions if connected socket = setup_additional_subscriptions(socket) # Handle callsign tracking - check path params first, then query params tracked_callsign = case Map.get(params, "callsign", Map.get(params, "call", "")) do "" -> "" nil -> "" 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 = BoundsUtils.calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom) # Final socket assignment {:ok, finalize_mount_assigns(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_hour_ago: one_hour_ago })} end defp setup_subscriptions(socket) do do_setup_subscriptions(socket, connected?(socket)) end defp do_setup_subscriptions(socket, true) do # Check if we should accept new connections if Application.get_env(:aprsme, :cluster_enabled, false) and not Aprsme.ConnectionMonitor.accepting_connections?() do # Redirect to another node or show message socket |> put_flash(:info, "This server is currently at capacity. Please try again in a moment.") |> push_event("redirect_to_least_loaded", %{}) |> assign(:connection_draining, true) else # Generate a unique client ID for this LiveView instance client_id = "liveview_#{:erlang.phash2(self())}" # Register with connection monitor _ = if Application.get_env(:aprsme, :cluster_enabled, false) do _ = Aprsme.ConnectionMonitor.register_connection() # Subscribe to drain events for this node Phoenix.PubSub.subscribe(Aprsme.PubSub, "connection:drain:#{Node.self()}") end # Register with spatial PubSub (will get viewport later) # Start with a default viewport that will be updated when we get actual bounds default_bounds = %{north: 90.0, south: -90.0, east: 180.0, west: -180.0} {:ok, spatial_topic} = Aprsme.SpatialPubSub.register_viewport(client_id, default_bounds) # Subscribe to the spatial topic for this client :ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, spatial_topic) # Still subscribe to bad packets (they don't have location) :ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets") # Subscribe to deployment events :ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "deployment_events") # Subscribe to StreamingPacketsPubSub with initial bounds _ = Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), default_bounds) _ = Process.send_after(self(), :cleanup_old_packets, 60_000) # Schedule UI update timer to refresh "time ago" display every 30 seconds _ = Process.send_after(self(), :update_time_display, 30_000) socket |> assign(:spatial_client_id, client_id) |> assign(:spatial_topic, spatial_topic) |> assign(:connection_draining, false) end end defp do_setup_subscriptions(socket, false), do: socket defp setup_additional_subscriptions(socket) do do_setup_additional_subscriptions(socket, connected?(socket)) end defp do_setup_additional_subscriptions(socket, true) do :ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") socket end defp do_setup_additional_subscriptions(socket, false), do: socket defp finalize_mount_assigns(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_hour_ago: one_hour_ago }) do # Don't override trail_duration and historical_hours if they're already set trail_duration = Map.get(socket.assigns, :trail_duration, "1") historical_hours = Map.get(socket.assigns, :historical_hours, "1") packet_age_threshold = Map.get(socket.assigns, :packet_age_threshold, one_hour_ago) # If tracking a specific callsign, fetch their latest packet and other SSIDs {tracked_callsign_latest_packet, other_ssids} = if tracked_callsign == "" do {nil, []} else try do packet = Packets.get_latest_packet_for_callsign(tracked_callsign) ssids = Packets.get_other_ssids(tracked_callsign) {packet, ssids} rescue # Handle database connection errors gracefully (especially in tests) DBConnection.OwnershipError -> {nil, []} _ -> {nil, []} end end # Start packet batcher for efficient updates {:ok, batcher_pid} = PacketBatcher.start_link(self()) Process.monitor(batcher_pid) # Determine initial slideover state from client viewport width slideover_open = initial_slideover_open?(socket) assign(socket, map_ready: false, map_bounds: initial_bounds, map_center: final_map_center, map_zoom: final_map_zoom, should_skip_initial_url_update: should_skip_initial_url_update, overlay_callsign: "", tracked_callsign: tracked_callsign, tracked_callsign_latest_packet: tracked_callsign_latest_packet, other_ssids: other_ssids, trail_duration: trail_duration, historical_hours: historical_hours, packet_age_threshold: packet_age_threshold, slideover_open: slideover_open, deployed_at: deployed_at, map_page: true, packet_buffer: [], buffer_timer: nil, batcher_pid: batcher_pid, station_popup_open: false, initial_bounds_loaded: false, # Always load historical data on initial page load needs_initial_historical_load: true, # Loading state management historical_loading: false, loading_generation: 0, pending_batch_tasks: [], # Add missing assigns for components loading: false, connection_status: get_initial_connection_status(), show_all_packets: true ) end defp get_initial_connection_status do # Check if APRS connection is disabled connection_disabled = Application.get_env(:aprsme, :disable_aprs_connection, false) if connection_disabled, do: "disconnected", else: "connected" end defp initial_slideover_open?(socket) do if connected?(socket) do viewport_width = get_connect_params(socket)["viewport_width"] || 1024 viewport_width >= 1024 else true end end @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t() defp assign_defaults(socket, one_hour_ago) do assign(socket, packets: [], visible_packets: %{}, historical_packets: %{}, page_title: "APRS Map", station_popup_open: false, map_bounds: %{ north: 49.0, south: 24.0, east: -66.0, west: -125.0 }, map_center: UrlParams.default_center(), map_zoom: UrlParams.default_zoom(), packet_age_threshold: one_hour_ago, map_ready: false, historical_loaded: false, bounds_update_timer: nil, pending_bounds: nil, initial_bounds_loaded: false, # Loading state management historical_loading: false, loading_generation: 0, pending_batch_tasks: [], # Overlay controls overlay_callsign: "", slideover_open: true, # Track when last update occurred for real-time display in map sidebar # Updated when packets are processed or map bounds change last_update_at: DateTime.utc_now(), # Add missing assigns for components loading: false, connection_status: get_initial_connection_status(), show_all_packets: true ) 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 require Logger Logger.debug("Received #{event} event with bounds: #{inspect(bounds)}") 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 # Geolocation sends numeric values; coerce strings to floats if needed lat_float = to_float(lat) lng_float = to_float(lng) if UrlParams.valid_coordinates?(lat_float, lng_float) do socket = Navigation.update_and_zoom_to_location(socket, lat_float, lng_float, 12) {:noreply, socket} else {:noreply, socket} end end @impl true def handle_event("clear_and_reload_markers", _params, socket) do # Filter by time and bounds filtered_packets = filter_packets_by_time_and_bounds_with_tracked( socket.assigns.visible_packets, socket.assigns.map_bounds, socket.assigns.packet_age_threshold, socket.assigns.tracked_callsign, socket.assigns.tracked_callsign_latest_packet ) visible_packets_list = DataBuilder.build_packet_data_list_from_map(filtered_packets, false, socket) # Check zoom level to decide between heat map and markers socket = if socket.assigns.map_zoom <= 8 do # Use heat map for low zoom levels send_heat_map_data(socket, filtered_packets) else # Use regular markers for high zoom levels add_markers_if_any(socket, visible_packets_list) end {:noreply, socket} end @impl true def handle_event("map_ready", _params, socket) do require Logger # Mark map as ready - preserve existing needs_initial_historical_load state # (it's already set correctly in mount based on whether we're tracking a callsign) socket = assign(socket, map_ready: true) # If we have non-default center coordinates (e.g., from geolocation), apply them now default_center = UrlParams.default_center() socket = if socket.assigns.map_center.lat == default_center.lat and socket.assigns.map_center.lng == default_center.lng do socket else Navigation.zoom_to_current_location(socket) end # If we're tracking a callsign and have its latest packet, display it immediately socket = if socket.assigns.tracked_callsign != "" and socket.assigns.tracked_callsign_latest_packet do packet = socket.assigns.tracked_callsign_latest_packet packet_data = DataBuilder.build_packet_data(packet) push_event(socket, "add_historical_packets_batch", %{ packets: [packet_data], batch: 0, is_final: false }) else socket end # Wait for JavaScript to send the actual map bounds before loading historical packets # The calculated bounds might be too small/inaccurate Logger.debug("Map ready - waiting for JavaScript to send actual bounds before loading historical packets") # If we need initial historical load and have bounds, trigger it socket = if socket.assigns.needs_initial_historical_load and socket.assigns.map_bounds do Logger.debug("Map ready with needs_initial_historical_load=true, triggering historical loading") # Send a message to trigger bounds processing send(self(), {:process_bounds_update, socket.assigns.map_bounds}) socket else socket end {:noreply, socket} end @impl true def handle_event("marker_clicked", _params, socket) do # When a marker is clicked, mark that a station popup is open {:noreply, assign(socket, station_popup_open: true)} end @impl true def handle_event("marker_hover_start", %{"id" => _id, "path" => path, "lat" => lat, "lng" => lng}, socket) do require Logger # Cancel any pending hover end timer socket = if socket.assigns[:hover_end_timer] do _ = Process.cancel_timer(socket.assigns.hover_end_timer) assign(socket, hover_end_timer: nil) else socket end # Validate coordinates first lat_float = ParamUtils.safe_parse_coordinate(lat, 0.0, -90.0, 90.0) lng_float = ParamUtils.safe_parse_coordinate(lng, 0.0, -180.0, 180.0) # Validate path string safe_path = ParamUtils.sanitize_path_string(path) # Parse the path to find digipeater/igate stations path_stations = RfPath.parse_rf_path(safe_path) # Query for positions of path stations path_station_positions = RfPath.get_path_station_positions(path_stations, socket) # Send event to draw the RF path lines socket = if path_station_positions == [] do socket else push_event(socket, "draw_rf_path", %{ station_lat: lat_float, station_lng: lng_float, path_stations: path_station_positions }) end {:noreply, socket} end @impl true def handle_event("marker_hover_end", _params, socket) do # Debounce hover end to prevent flicker during rapid mouse movements timer = Process.send_after(self(), :clear_rf_path, 100) {:noreply, assign(socket, hover_end_timer: timer)} end @impl true def handle_event("update_callsign", %{"callsign" => callsign}, socket) do {:noreply, assign(socket, overlay_callsign: callsign)} end @impl true def handle_event("track_callsign", %{"callsign" => callsign}, socket) do normalized_callsign = String.upcase(String.trim(callsign)) socket = if normalized_callsign == "" do # Clear tracking socket |> assign(tracked_callsign: "", other_ssids: []) |> update_url_with_current_state() else # Set tracking, fetch latest packet, zoom to location, and show marker other_ssids = Packets.get_other_ssids(normalized_callsign) latest_packet = Packets.get_latest_packet_for_callsign(normalized_callsign) socket = assign(socket, tracked_callsign: normalized_callsign, other_ssids: other_ssids, tracked_callsign_latest_packet: latest_packet ) # Zoom to the callsign's location and display its marker socket = if latest_packet && latest_packet.lat && latest_packet.lon do lat = Aprsme.EncodingUtils.to_float(latest_packet.lat) || 0.0 lon = Aprsme.EncodingUtils.to_float(latest_packet.lon) || 0.0 packet_data = DataBuilder.build_packet_data(latest_packet) socket |> push_event("zoom_to_location", %{lat: lat, lng: lon, zoom: 12}) |> push_event("add_historical_packets_batch", %{ packets: [packet_data], batch: 0, is_final: false }) else socket end push_patch(socket, to: "/#{normalized_callsign}") end {:noreply, socket} end @impl true def handle_event("clear_tracking", _params, socket) do socket = socket |> assign(tracked_callsign: "", overlay_callsign: "", other_ssids: []) |> push_event("clear_trail_line", %{}) |> update_url_with_current_state() {:noreply, socket} end @impl true def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket) do # Validate and convert duration string to hours hours = parse_trail_duration(duration) # Calculate new threshold safely new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold) # Update client-side TrailManager with new duration socket = push_event(socket, "update_trail_duration", %{duration_hours: hours}) # Update URL with new trail duration socket = update_url_with_current_state(socket) # Trigger cleanup to remove packets that are now outside the new duration send(self(), :cleanup_old_packets) # If tracking a callsign at low zoom, refresh the trail line with new duration socket = if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do DisplayManager.send_trail_line_for_tracked_callsign(socket) else socket end {:noreply, socket} end @impl true def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket) do # Validate hours value validated_hours = parse_historical_hours(hours) socket = assign(socket, historical_hours: to_string(validated_hours)) # Update URL with new historical hours socket = update_url_with_current_state(socket) # 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 callsign |> String.trim() |> String.upcase() |> handle_callsign_search(socket) 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("popup_closed", _params, socket) do # When any popup is closed, mark that no station popup is open {:noreply, assign(socket, station_popup_open: false)} end @impl true def handle_event("get_assigns", _params, socket) do send(self(), {:test_assigns, socket.assigns}) {:noreply, socket} end @impl true def handle_event("update_map_state", %{"center" => center, "zoom" => zoom} = params, socket) do require Logger Logger.debug("update_map_state event received: center=#{inspect(center)}, zoom=#{zoom}") # Parse and validate coordinates {lat, lng} = parse_center_coordinates(center, socket) zoom = clamp_zoom(zoom) map_center = %{lat: lat, lng: lng} # Update map state socket = update_map_state(socket, map_center, zoom) # Handle URL updates socket = handle_url_update(socket, lat, lng, zoom) # Process bounds if included socket = process_bounds_from_params(socket, params) {:noreply, socket} end @impl true def handle_event( "error_boundary_triggered", %{"message" => message, "stack" => stack, "component_id" => component_id}, socket ) do # Log the error for monitoring require Logger Logger.error("Error boundary triggered in component #{component_id}: #{message}\n#{stack}") # You could also send this to an error tracking service here # ErrorTracker.report_error(message, stack, %{component: component_id, user_id: socket.assigns[:current_user_id]}) {:noreply, socket} end defp parse_center_coordinates(center, socket) when is_map(center) do CoordinateUtils.parse_center_coordinates(center, socket) end defp parse_center_coordinates(_, socket) do CoordinateUtils.parse_center_coordinates(nil, socket) end defp clamp_zoom(zoom) do ParamUtils.clamp_zoom(zoom) end defp update_map_state(socket, map_center, zoom) do old_zoom = socket.assigns.map_zoom crossing_threshold = crossing_zoom_threshold?(old_zoom, zoom) socket = assign(socket, map_center: map_center, map_zoom: zoom) if crossing_threshold do handle_zoom_threshold_crossing(socket, zoom) else socket end end defp crossing_zoom_threshold?(old_zoom, new_zoom) do DisplayManager.crossing_zoom_threshold?(old_zoom, new_zoom) end defp handle_zoom_threshold_crossing(socket, zoom) do DisplayManager.handle_zoom_threshold_crossing(socket, zoom) end defp update_url_with_current_state(socket) do lat = socket.assigns.map_center.lat lng = socket.assigns.map_center.lng zoom = socket.assigns.map_zoom handle_url_update(socket, lat, lng, zoom) end defp handle_url_update(socket, lat, lng, zoom) do if socket.assigns[:should_skip_initial_url_update] && !socket.assigns[:initial_bounds_loaded] do require Logger Logger.debug("Skipping URL update on initial load") assign(socket, should_skip_initial_url_update: false) else require Logger # Include trail duration and historical hours in URL trail_param = if socket.assigns[:trail_duration] && socket.assigns[:trail_duration] != "1", do: "&trail=#{socket.assigns[:trail_duration]}", else: "" hist_param = if socket.assigns[:historical_hours] && socket.assigns[:historical_hours] != "1", do: "&hist=#{socket.assigns[:historical_hours]}", else: "" # Preserve callsign in path if tracking base_path = if socket.assigns.tracked_callsign == "" do "/" else "/#{socket.assigns.tracked_callsign}" end new_path = "#{base_path}?lat=#{lat}&lng=#{lng}&z=#{zoom}#{trail_param}#{hist_param}" Logger.debug("Updating URL to: #{new_path}") push_patch(socket, to: new_path, replace: true) end end defp process_bounds_from_params(socket, params) do case Map.get(params, "bounds") do %{"north" => north, "south" => south, "east" => east, "west" => west} -> map_bounds = %{north: north, south: south, east: east, west: west} if should_process_bounds?(socket, map_bounds) do require Logger Logger.debug( "Sending bounds update (initial_load: #{!socket.assigns[:initial_bounds_loaded]}, " <> "needs_historical: #{socket.assigns[:needs_initial_historical_load]}): #{inspect(map_bounds)}" ) send(self(), {:process_bounds_update, map_bounds}) end socket _ -> socket end end defp should_process_bounds?(socket, new_bounds) do socket.assigns.map_bounds != new_bounds or !socket.assigns[:initial_bounds_loaded] or socket.assigns[:needs_initial_historical_load] end defp handle_callsign_search(callsign, socket) do Navigation.handle_callsign_search(callsign, socket) end @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) defp to_float(val) when is_float(val), do: val defp to_float(val) when is_integer(val), do: val / 1 defp to_float(val) when is_binary(val) do case Float.parse(val) do {f, _} -> f :error -> nil end end defp to_float(_), do: nil @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(:update_time_display, socket), do: handle_update_time_display(socket) def handle_info(:reload_historical_packets, socket), do: handle_reload_historical_packets(socket) def handle_info({:postgres_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 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 def handle_info({:streaming_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 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 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) 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 require Logger 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 require Logger # 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 = 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 defp update_display_for_batch(socket, marker_data_list) do tracked_callsign = socket.assigns.tracked_callsign || "" if socket.assigns.map_zoom <= 8 do # If tracking a callsign, send trail line instead of heat map if tracked_callsign == "" do DisplayManager.send_heat_map_for_current_bounds(socket) else DisplayManager.send_trail_line_for_tracked_callsign(socket) end else send_marker_batch(socket, marker_data_list) end end 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_update_time_display(socket) do # Schedule next update Process.send_after(self(), :update_time_display, 30_000) {:noreply, assign(socket, :time_display_tick, System.monotonic_time())} end defp handle_reload_historical_packets(socket) do require Logger 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 # Fetch historical packets from the database # Select the best packet to display for a callsign - prioritize position over weather @spec filter_packets_by_time_and_bounds(map(), map(), DateTime.t()) :: map() defp filter_packets_by_time_and_bounds(packets, bounds, time_threshold) do packets |> Enum.filter(fn {_callsign, packet} -> BoundsUtils.within_bounds?(packet, bounds) && SharedPacketUtils.packet_within_time_threshold?(packet, time_threshold) end) |> Map.new() end @spec filter_packets_by_time_and_bounds_with_tracked(map(), map(), DateTime.t(), String.t(), map() | nil) :: map() defp filter_packets_by_time_and_bounds_with_tracked( packets, bounds, time_threshold, tracked_callsign, tracked_latest_packet ) do filtered = filter_packets_by_time_and_bounds(packets, bounds, time_threshold) # Always include the tracked callsign's latest packet if we have one if tracked_callsign != "" and tracked_latest_packet do key = get_callsign_key(tracked_latest_packet) Map.put(filtered, key, tracked_latest_packet) else filtered end end # Helper functions for marker operations @spec remove_markers_batch(Socket.t(), list()) :: Socket.t() defp remove_markers_batch(socket, []), do: socket defp remove_markers_batch(socket, marker_ids) do Enum.reduce(marker_ids, socket, fn id, acc -> push_event(acc, "remove_marker", %{id: id}) end) end @spec add_markers_if_any(Socket.t(), list()) :: Socket.t() defp add_markers_if_any(socket, []), do: socket defp add_markers_if_any(socket, markers) do push_event(socket, "add_markers", %{markers: markers}) end 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 # Unregister from connection monitor _ = if Application.get_env(:aprsme, :cluster_enabled, false) and not socket.assigns[:connection_draining] do Aprsme.ConnectionMonitor.unregister_connection() end # Cleanup spatial registration if we have a client ID _ = if socket.assigns[:spatial_client_id] do Aprsme.SpatialPubSub.unregister_client(socket.assigns.spatial_client_id) end # Unsubscribe from StreamingPacketsPubSub _ = Aprsme.StreamingPacketsPubSub.unsubscribe(self()) # Unsubscribe from PubSub topic subscribed in mount _ = Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "postgres:aprsme_packets") _ = if socket.assigns.buffer_timer, do: Process.cancel_timer(socket.assigns.buffer_timer) # Clean up any pending bounds update timer _ = if socket.assigns[:bounds_update_timer] do Process.cancel_timer(socket.assigns.bounds_update_timer) end # Clean up pending hover-end timer — set when the mouse leaves a marker # and only cleared when it re-enters; if the LV terminates between the two, # the timer would otherwise outlive the socket. _ = if socket.assigns[:hover_end_timer] do Process.cancel_timer(socket.assigns.hover_end_timer) end # Clean up any pending batch tasks _ = if socket.assigns[:pending_batch_tasks] do Enum.each(socket.assigns.pending_batch_tasks, &Process.cancel_timer/1) end :ok end # Bounds comparison now handled by shared utilities # --- Private bounds update helpers --- @spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()} defp handle_bounds_update(bounds, socket) do # Skip if we're currently loading historical data if socket.assigns.historical_loading do # Defer the bounds update {:noreply, assign(socket, pending_bounds: bounds)} else # Update the map bounds from the client, converting to atom keys map_bounds = BoundsUtils.normalize_bounds(bounds) # Validate bounds to prevent invalid coordinates if valid_bounds?(map_bounds) do handle_valid_bounds_update(map_bounds, socket) else # Invalid bounds, skip update {:noreply, socket} end end end defp valid_bounds?(map_bounds) do map_bounds.north <= 90 and map_bounds.south >= -90 and map_bounds.north > map_bounds.south end defp handle_valid_bounds_update(map_bounds, socket) do # Force processing if we need initial historical load, regardless of bounds comparison cond do socket.assigns[:needs_initial_historical_load] -> require Logger Logger.debug("Processing initial bounds update immediately (forced): #{inspect(map_bounds)}") socket = process_bounds_update(map_bounds, socket) {:noreply, socket} BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds) -> {:noreply, socket} true -> # For subsequent updates, use the timer to debounce schedule_bounds_update(map_bounds, socket) end end # Configurable debounce delay (in milliseconds) @bounds_update_debounce_ms Application.compile_env(:aprsme, :bounds_update_debounce_ms, 400) defp schedule_bounds_update(map_bounds, socket) do # Cancel existing timer if present if socket.assigns[:bounds_update_timer] do case Process.cancel_timer(socket.assigns.bounds_update_timer) do false -> # Timer already fired, receive the message to clear it receive do {:process_bounds_update, _} -> :ok after 0 -> :ok end _ -> :ok end end # Cancel any in-progress historical loading to prevent race conditions socket = HistoricalLoader.cancel_pending_loads(socket) # Use configurable debounce time for better stability during rapid zooming timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, @bounds_update_debounce_ms) socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds) {:noreply, socket} end defp send_heat_map_data(socket, filtered_packets) do # Convert map of packets to list packet_list = Map.values(filtered_packets) send_heat_map_for_packets(socket, packet_list) end # Common heat map display logic defp send_heat_map_for_packets(socket, packets) do # Get clustering data case Clustering.cluster_packets(packets, socket.assigns.map_zoom) do {:heat_map, heat_points} -> push_event(socket, "show_heat_map", %{heat_points: heat_points}) {:raw_packets, _packets} -> # Shouldn't happen at zoom <= 8, but handle it anyway socket end end @spec process_bounds_update(map(), Socket.t()) :: Socket.t() defp process_bounds_update(map_bounds, socket) do require Logger Logger.debug("process_bounds_update called with bounds: #{inspect(map_bounds)}") sync_pubsub_bounds(socket, map_bounds) bounds_state = bounds_update_state(socket, map_bounds) log_bounds_update_state(bounds_state) {new_visible_packets, packets_to_remove} = filtered_packets_for_bounds(socket.assigns.visible_packets, map_bounds) socket = socket |> remove_markers_batch(packets_to_remove) |> maybe_clear_historical_packets(bounds_state) |> push_event("filter_markers_by_bounds", %{bounds: map_bounds}) |> assign(map_bounds: map_bounds, visible_packets: new_visible_packets) |> assign(needs_initial_historical_load: false) log_historical_reload(socket) socket = HistoricalLoader.start_progressive_historical_loading(socket) # If tracking a callsign and at low zoom, send trail line instead of letting # historical loader send heat map socket = if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do DisplayManager.send_trail_line_for_tracked_callsign(socket) else socket end # Mark initial historical as completed if this was the initial load if bounds_state.is_initial_load do assign(socket, initial_historical_completed: true) else socket end end defp sync_pubsub_bounds(socket, map_bounds) do if socket.assigns[:spatial_client_id] do Aprsme.SpatialPubSub.update_viewport(socket.assigns.spatial_client_id, map_bounds) end Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), map_bounds) end defp bounds_update_state(socket, map_bounds) do %{ is_initial_load: socket.assigns[:needs_initial_historical_load] || !socket.assigns[:initial_bounds_loaded], bounds_changed: socket.assigns.map_bounds && not BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds), initial_historical_completed: socket.assigns[:initial_historical_completed] || false } end defp log_bounds_update_state(bounds_state) do require Logger Logger.debug( "is_initial_load: #{bounds_state.is_initial_load}, bounds_changed: #{bounds_state.bounds_changed}, " <> "initial_historical_completed: #{bounds_state.initial_historical_completed}" ) end defp filtered_packets_for_bounds(visible_packets, map_bounds) do { BoundsUtils.filter_packets_by_bounds(visible_packets, map_bounds), BoundsUtils.reject_packets_by_bounds(visible_packets, map_bounds) } end defp maybe_clear_historical_packets(socket, %{ bounds_changed: true, is_initial_load: false, initial_historical_completed: true }) do require Logger Logger.debug("Bounds changed after initial load - clearing historical packets") push_event(socket, "clear_historical_packets", %{}) end defp maybe_clear_historical_packets(socket, _bounds_state) do require Logger Logger.debug("Initial load or no significant change - keeping existing markers") socket end defp log_historical_reload(socket) do require Logger Logger.debug("Starting progressive historical loading for new bounds") Logger.debug( "Current assigns: historical_loading=#{socket.assigns.historical_loading}, map_bounds=#{inspect(socket.assigns.map_bounds)}" ) end end