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, push_event: 3, push_patch: 2, put_flash: 3] # Import the new components module alias Aprsme.Packets alias Aprsme.Packets.Clustering alias AprsmeWeb.Endpoint 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.BoundsManager alias AprsmeWeb.MapLive.DataBuilder alias AprsmeWeb.MapLive.DisplayManager alias AprsmeWeb.MapLive.HistoricalLoader alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.Navigation alias AprsmeWeb.MapLive.PacketBatcher alias AprsmeWeb.MapLive.PacketManager alias AprsmeWeb.MapLive.PacketProcessor alias AprsmeWeb.MapLive.RfPath alias AprsmeWeb.MapLive.UrlParams alias AprsmeWeb.TimeUtils 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 = BoundsManager.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 Phoenix.PubSub.subscribe(Aprsme.PubSub, spatial_topic) # Still subscribe to bad packets (they don't have location) Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets") # Subscribe to deployment events 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 Endpoint.subscribe("aprs_messages") 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 tracked_callsign_latest_packet = if tracked_callsign == "" do nil else try do Packets.get_latest_packet_for_callsign(tracked_callsign) 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()) 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, packet_state: PacketManager.init_packet_state(), overlay_callsign: "", tracked_callsign: tracked_callsign, tracked_callsign_latest_packet: tracked_callsign_latest_packet, trail_duration: trail_duration, historical_hours: historical_hours, packet_age_threshold: packet_age_threshold, slideover_open: true, 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 @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t() defp assign_defaults(socket, one_hour_ago) do assign(socket, packets: [], all_packets: %{}, visible_packets: %{}, historical_packets: %{}, page_title: "APRS Map", packet_state: PacketManager.init_packet_state(), 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 state - will be set based on screen size 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 # Update map center and zoom when location is received # Parse and validate coordinates lat_float = UrlParams.parse_latitude(lat) lng_float = UrlParams.parse_longitude(lng) # Additional validation - ensure we got valid coordinates 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 # Invalid coordinates - ignore the event {:noreply, socket} end end @impl true def handle_event("clear_and_reload_markers", _params, socket) do # Get current visible packets from PacketManager current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state) # Filter by time and bounds filtered_packets = filter_packets_by_time_and_bounds_with_tracked( Map.new(current_packets, fn packet -> {get_callsign_key(packet), packet} end), 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: "") |> update_url_with_current_state() else # Set tracking and navigate to callsign URL socket |> assign(tracked_callsign: normalized_callsign) |> push_patch(to: "/#{normalized_callsign}") end {:noreply, socket} end @impl true def handle_event("clear_tracking", _params, socket) do socket = socket |> assign(tracked_callsign: "", overlay_callsign: "") |> 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) {: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) @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 # Process batch of packets efficiently socket = Enum.reduce(packets, socket, fn packet, acc_socket -> case handle_info_postgres_packet(packet, acc_socket) do {:noreply, new_socket} -> new_socket _ -> acc_socket end end) {:noreply, socket} 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 # Check if this connection should be drained # Use a random selection to determine if this connection should disconnect if :rand.uniform(100) <= to_drain * 10 do # Gracefully disconnect this client 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 station_packets = stations |> Enum.map(fn callsign -> Packets.get_latest_packet_for_callsign(callsign) end) |> Enum.filter(& &1) 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(%Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket), do: handle_info({:postgres_packet, packet}, socket) 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 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, 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 # 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 /> <.slideover_panel slideover_open={@slideover_open} loading={@loading} connection_status={@connection_status} packets={@packets} show_all_packets={@show_all_packets} tracked_callsign={@tracked_callsign} tracked_callsign_latest_packet={@tracked_callsign_latest_packet} /> <.toggle_button slideover_open={@slideover_open} /> <.bottom_controls {assigns} /> """ end # Additional component functions that weren't extracted yet defp locate_button(assigns) do ~H""" """ end defp toggle_button(assigns) do ~H""" """ 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

<%= if @tracked_callsign != "" do %>
{gettext("Tracking:")} {@tracked_callsign}
<% end %>
{gettext("Navigation")}
<.navigation variant={:vertical} class="text-sm" current_user={@current_user} map_state={%{lat: @map_center.lat, lng: @map_center.lng, zoom: @map_zoom}} tracked_callsign={@tracked_callsign} />
{gettext("Last Update")}
<%= if @last_update_at do %>
{time_ago_in_words(@last_update_at)}
{Calendar.strftime(@last_update_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
No updates yet
<% end %>
{gettext("Last Deploy")}
{time_ago_in_words(@deployed_at)}
{Calendar.strftime(@deployed_at, "%Y-%m-%d %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) # Use the current packet_age_threshold instead of hardcoded one hour threshold = socket.assigns.packet_age_threshold # Remove expired packets using PacketManager predicate updated_packet_state = PacketManager.remove_packets_where(socket.assigns.packet_state, fn packet -> SharedPacketUtils.packet_within_time_threshold?(packet, threshold) end) # Get expired packet IDs that need to be removed from client current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state) remaining_packets = PacketManager.get_visible_packets(updated_packet_state) expired_keys = current_packets |> Enum.reject(fn current_packet -> Enum.any?(remaining_packets, fn remaining_packet -> get_callsign_key(current_packet) == get_callsign_key(remaining_packet) end) end) |> Enum.map(&get_callsign_key/1) # Only update the client if there are expired markers socket = DisplayManager.remove_markers_batch(socket, expired_keys) {:noreply, assign(socket, packet_state: updated_packet_state)} end defp handle_update_time_display(socket) do # Schedule next update Process.send_after(self(), :update_time_display, 30_000) # Simply triggering a re-render will cause time_ago_in_words to recalculate # No need to update any assigns, just return the socket {:noreply, socket} 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 within_bounds?(map() | struct(), map()) :: boolean() defp within_bounds?(packet, bounds) do {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) # Basic validation check_coordinate_bounds(lat, lon, bounds) end defp check_coordinate_bounds(nil, _, _), do: false defp check_coordinate_bounds(_, nil, _), do: false defp check_coordinate_bounds(lat, lon, bounds) do # Check latitude bounds (straightforward) lat_in_bounds = lat >= bounds.south && lat <= bounds.north # Check longitude bounds (handle potential wrapping) lng_in_bounds = check_longitude_bounds(lon, bounds.west, bounds.east) lat_in_bounds && lng_in_bounds end defp check_longitude_bounds(lon, west, east) when west <= east do # Normal case: bounds don't cross antimeridian lon >= west && lon <= east end defp check_longitude_bounds(lon, west, east) do # Bounds cross antimeridian (e.g., west=170, east=-170) lon >= west || lon <= east end # Helper functions to reduce duplicate filtering logic @spec filter_packets_by_bounds(map(), map()) :: map() defp filter_packets_by_bounds(packets_map, bounds) when is_map(packets_map) do packets_map |> Enum.filter(fn {_k, packet} -> within_bounds?(packet, bounds) end) |> Map.new() end @spec filter_packets_by_bounds(list(), map()) :: list() defp filter_packets_by_bounds(packets_list, bounds) when is_list(packets_list) do Enum.filter(packets_list, &within_bounds?(&1, bounds)) end @spec reject_packets_by_bounds(map(), map()) :: list() defp reject_packets_by_bounds(packets_map, bounds) when is_map(packets_map) do packets_map |> Enum.reject(fn {_k, packet} -> within_bounds?(packet, bounds) end) |> Enum.map(fn {k, _} -> k end) end @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} -> 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 @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()) 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 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 = MapHelpers.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)}") # Update spatial viewport if we have a client ID if socket.assigns[:spatial_client_id] do Aprsme.SpatialPubSub.update_viewport(socket.assigns.spatial_client_id, map_bounds) end # Update StreamingPacketsPubSub subscription with new bounds Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), map_bounds) # Check if this is the initial load or if bounds have actually changed 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) # Check if we've completed the initial historical load initial_historical_completed = socket.assigns[:initial_historical_completed] || false Logger.debug( "is_initial_load: #{is_initial_load}, bounds_changed: #{bounds_changed}, initial_historical_completed: #{initial_historical_completed}" ) # Remove out-of-bounds packets and markers immediately current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state) current_packets_map = Map.new(current_packets, fn packet -> {get_callsign_key(packet), packet} end) new_visible_packets = filter_packets_by_bounds(current_packets_map, map_bounds) packets_to_remove = reject_packets_by_bounds(current_packets_map, map_bounds) # Remove markers for out-of-bounds packets socket = remove_markers_batch(socket, packets_to_remove) # Only clear historical packets if: # 1. Bounds actually changed AND # 2. This is not the initial load AND # 3. We've already completed the initial historical load socket = if bounds_changed and not is_initial_load and initial_historical_completed do Logger.debug("Bounds changed after initial load - clearing historical packets") push_event(socket, "clear_historical_packets", %{}) else Logger.debug("Initial load or no significant change - keeping existing markers") socket end # Always filter markers by bounds socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds}) # Update packet state to keep only packets within bounds filtered_packets = Map.values(new_visible_packets) {updated_packet_state, _} = PacketManager.add_visible_packets(socket.assigns.packet_state, filtered_packets) # Update map bounds FIRST so progressive loading uses the correct bounds socket = socket |> assign(map_bounds: map_bounds, packet_state: updated_packet_state) |> assign(needs_initial_historical_load: false) # Load historical packets for the new bounds (now socket.assigns.map_bounds is correct) 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)}" ) socket = HistoricalLoader.start_progressive_historical_loading(socket) # Mark initial historical as completed if this was the initial load if is_initial_load do assign(socket, initial_historical_completed: true) else socket end end end