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.TimeHelpers, only: [time_ago_in_words: 1] import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2] alias Aprsme.GeoUtils alias Aprsme.Packets.Clustering alias AprsmeWeb.Endpoint alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.MapLive.PopupComponent alias AprsmeWeb.TimeUtils alias Phoenix.HTML.Safe alias Phoenix.LiveView.Socket @default_center %{lat: 39.8283, lng: -98.5795} @default_zoom 5 # Parse map state from URL parameters @spec parse_map_params(map()) :: {map(), integer()} defp parse_map_params(params) do # Parse latitude (lat parameter) lat = case Map.get(params, "lat") do nil -> @default_center.lat lat_str -> case Float.parse(lat_str) do {lat_val, _} when lat_val >= -90 and lat_val <= 90 -> lat_val _ -> @default_center.lat end end # Parse longitude (lng parameter) lng = case Map.get(params, "lng") do nil -> @default_center.lng lng_str -> case Float.parse(lng_str) do {lng_val, _} when lng_val >= -180 and lng_val <= 180 -> lng_val _ -> @default_center.lng end end # Parse zoom level (z parameter) zoom = case Map.get(params, "z") do nil -> @default_zoom zoom_str -> case Integer.parse(zoom_str) do {zoom_val, _} when zoom_val >= 1 and zoom_val <= 20 -> zoom_val _ -> @default_zoom end end map_center = %{lat: lat, lng: lng} {map_center, zoom} end @impl true def mount(params, session, socket) do require Logger if connected?(socket) do # Subscribe to packet updates Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets") # Schedule periodic cleanup of old packets Process.send_after(self(), :cleanup_old_packets, 60_000) end # Get deployment timestamp from config (set during application startup) deployed_at = Aprsme.Release.deployed_at() # Show 24 hours for more symbol variety one_hour_ago = TimeUtils.one_day_ago() # Parse map state from URL parameters {url_center, url_zoom} = parse_map_params(params) # Check for IP geolocation in session # Check if URL params were explicitly provided (not just defaults) has_explicit_url_params = params["lat"] || params["lng"] || params["z"] {map_center, map_zoom, should_skip_initial_url_update} = case session["ip_geolocation"] do %{"lat" => lat, "lng" => lng} when is_number(lat) and is_number(lng) -> if has_explicit_url_params do # URL params explicitly provided - use them {url_center, url_zoom, false} else # No explicit URL params - use IP geolocation geo_center = %{lat: lat, lng: lng} {geo_center, 11, true} end _ -> # No geolocation available, use URL params or defaults # Skip initial URL update if no explicit params were provided {url_center, url_zoom, !has_explicit_url_params} end socket = assign_defaults(socket, one_hour_ago) # Initialize the flag to track if initial historical load is completed socket = assign(socket, initial_historical_completed: false) # Calculate initial bounds based on center and zoom level initial_bounds = calculate_bounds_from_center_and_zoom(map_center, map_zoom) if connected?(socket) do Endpoint.subscribe("aprs_messages") Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") end # Check for callsign parameter tracked_callsign = Map.get(params, "call", "") {:ok, assign(socket, map_ready: false, map_bounds: initial_bounds, map_center: map_center, map_zoom: map_zoom, should_skip_initial_url_update: should_skip_initial_url_update, visible_packets: %{}, historical_packets: %{}, 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, all_packets: %{}, station_popup_open: false, initial_bounds_loaded: false, needs_initial_historical_load: false )} end # Calculate approximate bounds based on center point and zoom level # This provides initial bounds for database queries before client sends actual bounds @spec calculate_bounds_from_center_and_zoom(map(), integer()) :: map() defp calculate_bounds_from_center_and_zoom(center, zoom) do # Approximate degrees per pixel at different zoom levels # These are rough estimates for initial bounds calculation degrees_per_pixel = case zoom do z when z >= 15 -> 0.000005 z when z >= 12 -> 0.00005 z when z >= 10 -> 0.0002 z when z >= 8 -> 0.001 z when z >= 6 -> 0.005 z when z >= 4 -> 0.02 _ -> 0.1 end # Assume viewport is roughly 800x600 pixels # Half of 600px height lat_offset = degrees_per_pixel * 300 # Half of 800px width lng_offset = degrees_per_pixel * 400 %{ north: center.lat + lat_offset, south: center.lat - lat_offset, east: center.lng + lng_offset, west: center.lng - lng_offset } end @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t() defp assign_defaults(socket, one_hour_ago) do assign(socket, packets: [], page_title: "APRS Map", visible_packets: %{}, station_popup_open: false, map_bounds: %{ north: 49.0, south: 24.0, east: -66.0, west: -125.0 }, map_center: @default_center, map_zoom: @default_zoom, historical_packets: %{}, packet_age_threshold: one_hour_ago, map_ready: false, historical_loaded: false, bounds_update_timer: nil, pending_bounds: nil, initial_bounds_loaded: false, # Overlay controls overlay_callsign: "", trail_duration: "1", historical_hours: "1", # Slideover state - will be set based on screen size slideover_open: true ) end @impl true def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do handle_bounds_update(bounds, socket) end @impl true def handle_event("update_bounds", %{"bounds" => bounds}, socket) do handle_bounds_update(bounds, socket) end @impl true def handle_event("locate_me", _params, socket) do # Send JavaScript command to request browser geolocation {:noreply, push_event(socket, "request_geolocation", %{})} end @impl true def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do # Update map center and zoom when location is received # Ensure coordinates are floats lat_float = cond do is_binary(lat) -> String.to_float(lat) is_integer(lat) -> lat / 1.0 true -> lat end lng_float = cond do is_binary(lng) -> String.to_float(lng) is_integer(lng) -> lng / 1.0 true -> lng end socket = socket |> assign(map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12) |> push_event("zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12}) {:noreply, socket} end @impl true def handle_event("clear_and_reload_markers", _params, socket) do # Only filter the current visible_packets, do not re-query the database filtered_packets = socket.assigns.visible_packets |> Enum.filter(fn {_callsign, packet} -> within_bounds?(packet, socket.assigns.map_bounds) && packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold) end) |> Map.new() locale = Map.get(socket.assigns, :locale, "en") visible_packets_list = filtered_packets |> Enum.map(fn {_callsign, packet} -> PacketUtils.build_packet_data(packet, false, locale) end) |> Enum.filter(& &1) socket = assign(socket, visible_packets: filtered_packets) # 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 if Enum.any?(visible_packets_list) do socket |> push_event("show_markers", %{}) |> push_event("add_markers", %{markers: visible_packets_list}) else socket end end {:noreply, socket} end @impl true def handle_event("map_ready", _params, socket) do require Logger # Mark map as ready and that we need to load historical packets socket = socket |> assign(map_ready: true) |> assign(needs_initial_historical_load: true) # If we have non-default center coordinates (e.g., from geolocation), apply them now socket = if socket.assigns.map_center.lat == @default_center.lat and socket.assigns.map_center.lng == @default_center.lng do socket else push_event(socket, "zoom_to_location", %{ lat: socket.assigns.map_center.lat, lng: socket.assigns.map_center.lng, zoom: socket.assigns.map_zoom }) 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("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 # Convert duration string to hours and calculate new threshold hours = String.to_integer(duration) new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold) # Trigger cleanup to remove packets that are now outside the new duration send(self(), :cleanup_old_packets) {:noreply, socket} end @impl true def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket) do socket = assign(socket, historical_hours: hours) # Trigger a reload of historical packets with the new time range if socket.assigns.map_ready do send(self(), :reload_historical_packets) end {:noreply, socket} end @impl true def handle_event("search_callsign", %{"callsign" => callsign}, socket) do trimmed_callsign = callsign |> String.trim() |> String.upcase() if trimmed_callsign == "" do {:noreply, socket} else # Navigate to the callsign-specific route {:noreply, push_navigate(socket, to: "/#{trimmed_callsign}")} end end @impl true def handle_event("toggle_slideover", _params, socket) do {:noreply, assign(socket, slideover_open: !socket.assigns.slideover_open)} end @impl true def handle_event("set_slideover_state", %{"open" => open}, socket) do {:noreply, assign(socket, slideover_open: open)} end @impl true def handle_event("geolocation_error", %{"error" => _error}, socket) do # Handle geolocation errors gracefully - just continue without geolocation {:noreply, socket} end @impl true def handle_event("request_geolocation", _params, socket) do # This event is handled by the JavaScript hook {:noreply, socket} end @impl true def handle_event("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 center coordinates lat = case center do %{"lat" => lat_val} -> lat_val _ -> socket.assigns.map_center.lat end lng = case center do %{"lng" => lng_val} -> lng_val _ -> socket.assigns.map_center.lng end # Validate and clamp values lat = max(-90.0, min(90.0, lat)) lng = max(-180.0, min(180.0, lng)) zoom = max(1, min(20, zoom)) map_center = %{lat: lat, lng: lng} # Check if we're crossing the heat map/marker threshold old_zoom = socket.assigns.map_zoom crossing_threshold = (old_zoom <= 8 and zoom > 8) or (old_zoom > 8 and zoom <= 8) # Update socket state socket = assign(socket, map_center: map_center, map_zoom: zoom) # If crossing threshold, trigger appropriate display mode socket = if crossing_threshold do if zoom <= 8 do # Switching to heat map socket = push_event(socket, "clear_all_markers", %{}) send_heat_map_for_current_bounds(socket) else # Switching to markers trigger_marker_display(socket) end else socket end # Update URL without page reload, but skip on initial load if requested socket = if socket.assigns[:should_skip_initial_url_update] && !socket.assigns[:initial_bounds_loaded] do # Skip URL update on initial load Logger.debug("Skipping URL update on initial load") # Clear the flag after first update assign(socket, should_skip_initial_url_update: false) else new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}" Logger.debug("Updating URL to: #{new_path}") push_patch(socket, to: new_path, replace: true) end # If bounds are included, also process bounds update socket = case Map.get(params, "bounds") do %{"north" => north, "south" => south, "east" => east, "west" => west} -> map_bounds = %{ north: north, south: south, east: east, west: west } # Trigger bounds processing if bounds changed OR if this is the initial load OR if we need initial historical load if socket.assigns.map_bounds != map_bounds or !socket.assigns[:initial_bounds_loaded] or socket.assigns[:needs_initial_historical_load] 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 {: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 @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} = 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 @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({:load_historical_batch, batch_offset}, socket) do socket = load_historical_batch(socket, batch_offset) {:noreply, socket} end def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket), do: handle_info({:postgres_packet, packet}, socket) # Private handler functions for each message type defp handle_info_process_bounds_update(map_bounds, socket) do 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 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 = 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 {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) callsign_key = get_callsign_key(packet) # Update all_packets all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet) socket = assign(socket, all_packets: all_packets) # Handle packet visibility logic handle_packet_visibility(packet, lat, lon, callsign_key, socket) end defp get_callsign_key(packet) do if Map.has_key?(packet, "id"), do: to_string(packet["id"]), else: System.unique_integer([:positive]) end defp handle_packet_visibility(packet, lat, lon, callsign_key, socket) do cond do should_remove_marker?(lat, lon, callsign_key, socket) -> remove_marker_from_map(callsign_key, socket) should_add_marker?(lat, lon, callsign_key, socket) -> handle_valid_postgres_packet(packet, lat, lon, socket) should_update_marker?(lat, lon, callsign_key, socket) -> # Marker exists and is within bounds - check if there's significant movement existing_packet = socket.assigns.visible_packets[callsign_key] {existing_lat, existing_lon, _} = MapHelpers.get_coordinates(existing_packet) # Check if we have valid existing coordinates if is_number(existing_lat) and is_number(existing_lon) and GeoUtils.significant_movement?(existing_lat, existing_lon, lat, lon, 15) do # Significant movement detected (more than 15 meters), update the marker handle_valid_postgres_packet(packet, lat, lon, socket) else # Just GPS drift or invalid coordinates, update the packet data but don't send visual update new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet) socket = assign(socket, visible_packets: new_visible_packets) {:noreply, socket} end true -> {:noreply, socket} end end defp should_remove_marker?(lat, lon, callsign_key, socket) do !is_nil(lat) and !is_nil(lon) and Map.has_key?(socket.assigns.visible_packets, callsign_key) and not MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) end defp should_add_marker?(lat, lon, callsign_key, socket) do !is_nil(lat) and !is_nil(lon) and not Map.has_key?(socket.assigns.visible_packets, callsign_key) and MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) end defp should_update_marker?(lat, lon, callsign_key, socket) do !is_nil(lat) and !is_nil(lon) and Map.has_key?(socket.assigns.visible_packets, callsign_key) and MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) end defp remove_marker_from_map(callsign_key, socket) do socket = push_event(socket, "remove_marker", %{id: callsign_key}) new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key) {:noreply, assign(socket, visible_packets: new_visible_packets)} end defp handle_valid_postgres_packet(packet, _lat, _lon, socket) do # Add the packet to visible_packets and push a marker immediately callsign_key = if Map.has_key?(packet, "id"), do: to_string(packet["id"]), else: System.unique_integer([:positive]) new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet) socket = assign(socket, visible_packets: new_visible_packets) # Check zoom level to decide how to display the packet socket = if socket.assigns.map_zoom <= 8 do # We're in heat map mode - update the heat map with all current data send_heat_map_for_current_bounds(socket) else # We're in marker mode - send individual marker locale = Map.get(socket.assigns, :locale, "en") marker_data = PacketUtils.build_packet_data(packet, true, locale) if marker_data do # Only show new packet popup if no station popup is currently open if socket.assigns.station_popup_open do # Send without opening popup to avoid interrupting user push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false)) else push_event(socket, "new_packet", marker_data) end else socket end end {:noreply, socket} end # Handle replaying the next historical packet @impl true def render(assigns) do ~H""" <.error_boundary id="map-error-boundary">