defmodule AprsmeWeb.MapLive.Index do @moduledoc """ LiveView for displaying real-time APRS packets on a map """ use AprsmeWeb, :live_view import AprsmeWeb.Components.ErrorBoundary import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1] import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_patch: 2, put_flash: 3] 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.PacketManager alias AprsmeWeb.MapLive.PacketProcessor alias AprsmeWeb.MapLive.RfPath alias AprsmeWeb.MapLive.UrlParams alias AprsmeWeb.TimeUtils alias Phoenix.LiveView.Socket @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) # Setup defaults socket = assign_defaults(socket, one_hour_ago) socket = assign(socket, initial_historical_completed: false) # Setup additional subscriptions if connected socket = setup_additional_subscriptions(socket) # Handle callsign tracking tracked_callsign = Map.get(params, "call", "") {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 # Generate a unique client ID for this LiveView instance client_id = "liveview_#{:erlang.phash2(self())}" # 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") Process.send_after(self(), :cleanup_old_packets, 60_000) socket |> assign(:spatial_client_id, client_id) |> assign(:spatial_topic, spatial_topic) 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 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, trail_duration: "1", historical_hours: "1", packet_age_threshold: one_hour_ago, slideover_open: true, deployed_at: deployed_at, map_page: true, packet_buffer: [], buffer_timer: nil, station_popup_open: false, initial_bounds_loaded: false, needs_initial_historical_load: tracked_callsign != "", # Loading state management historical_loading: false, loading_generation: 0, pending_batch_tasks: [] ) 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: "", trail_duration: "1", historical_hours: "1", # Slideover state - will be set based on screen size slideover_open: 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 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( Map.new(current_packets, fn packet -> {get_callsign_key(packet), packet} end), socket.assigns.map_bounds, socket.assigns.packet_age_threshold ) 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 # 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") {: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 # 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) Logger.info("RF path hover start - path: #{inspect(safe_path)}") # Parse the path to find digipeater/igate stations path_stations = RfPath.parse_rf_path(safe_path) Logger.info("Parsed path stations: #{inspect(path_stations)}") # Query for positions of path stations path_station_positions = RfPath.get_path_station_positions(path_stations, socket) Logger.info("Found #{length(path_station_positions)} station positions") # Send event to draw the RF path lines socket = if length(path_station_positions) > 0 do push_event(socket, "draw_rf_path", %{ station_lat: lat_float, station_lng: lng_float, path_stations: path_station_positions }) else socket end {:noreply, socket} end @impl true def handle_event("marker_hover_end", _params, socket) do # Clear the RF path lines {:noreply, push_event(socket, "clear_rf_path", %{})} 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: "") |> push_patch(to: "/") else # Set tracking socket |> assign(tracked_callsign: normalized_callsign) |> push_patch(to: "/?call=#{normalized_callsign}") end {:noreply, socket} end @impl true def handle_event("clear_tracking", _params, socket) do socket = socket |> assign(tracked_callsign: "", overlay_callsign: "") |> push_patch(to: "/") {: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) # 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)) # 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 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 new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}" 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) # Update socket state socket = assign(socket, map_center: map_center, map_zoom: map_zoom) # If map is ready, update the client-side map socket = if socket.assigns.map_ready do push_event(socket, "zoom_to_location", %{ lat: map_center.lat, lng: map_center.lng, zoom: map_zoom }) else socket 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({:postgres_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket) def handle_info({:spatial_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket) 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(%Phoenix.Socket.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 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 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 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""" <.error_boundary id="map-error-boundary">