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 AprsmeWeb.Endpoint alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.MapLive.PopupComponent 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 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 = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) # Parse map state from URL parameters {map_center, map_zoom} = parse_map_params(params) socket = assign_defaults(socket, one_hour_ago) socket = assign(socket, map_center: map_center, map_zoom: map_zoom) # Calculate initial bounds based on center and zoom level initial_bounds = calculate_bounds_from_center_and_zoom(map_center, map_zoom) socket = assign(socket, map_bounds: initial_bounds) socket = assign(socket, packet_buffer: [], buffer_timer: nil) socket = assign(socket, all_packets: %{}, station_popup_open: false) if connected?(socket) do Endpoint.subscribe("aprs_messages") Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") # Defer IP geolocation to avoid blocking initial page load Process.send_after(self(), :start_geolocation, 2000) end {:ok, assign(socket, map_ready: false, map_bounds: nil, map_center: %{lat: 39.8283, lng: -98.5795}, map_zoom: 4, visible_packets: %{}, historical_packets: %{}, overlay_callsign: "", trail_duration: "1", historical_hours: "1", packet_age_threshold: one_hour_ago, slideover_open: true, deployed_at: deployed_at, map_page: true )} 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, pending_geolocation: nil, 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) socket = if Enum.any?(visible_packets_list) do push_event(socket, "add_markers", %{markers: visible_packets_list}) else socket end {:noreply, socket} end @impl true def handle_event("map_ready", _params, socket) do socket = assign(socket, map_ready: true) # Load historical packets immediately since we now have bounds from URL parameters Process.send_after(self(), :reload_historical_packets, 10) # If we have pending geolocation, zoom to it now socket = if socket.assigns.pending_geolocation do %{lat: lat, lng: lng} = socket.assigns.pending_geolocation push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) 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("update_callsign", %{"callsign" => callsign}, socket) do {:noreply, assign(socket, overlay_callsign: callsign)} 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 # 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} # Update socket state socket = assign(socket, map_center: map_center, map_zoom: zoom) # Update URL without page reload new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}" socket = push_patch(socket, to: new_path, replace: true) # 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 } # Only trigger bounds processing if bounds actually changed if socket.assigns.map_bounds != map_bounds do 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 # 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 @impl true def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket) def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket), do: handle_info_delayed_zoom(lat, lng, socket) def handle_info({:ip_location, %{lat: lat, lng: lng}}, socket), do: handle_info_ip_location(lat, lng, 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(:start_geolocation, socket), do: handle_info_start_geolocation(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 if !socket.assigns.initial_bounds_loaded or not compare_bounds(map_bounds, socket.assigns.map_bounds) do socket = process_bounds_update(map_bounds, socket) socket = assign(socket, initial_bounds_loaded: true) {:noreply, socket} else {:noreply, socket} end end defp handle_info_delayed_zoom(lat, lng, socket) do socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) {:noreply, socket} end defp handle_info_ip_location(lat, lng, socket) do 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 # Schedule a delayed zoom to give the user a moment to see the map Process.send_after(self(), {:delayed_zoom, %{lat: lat_float, lng: lng_float}}, 500) # We can still optimistically set the center and zoom socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12) {:noreply, socket} 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 {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) 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 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) # Live packets are always the most recent for their callsign locale = Map.get(socket.assigns, :locale, "en") marker_data = PacketUtils.build_packet_data(packet, true, locale) socket = 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 {:noreply, assign(socket, visible_packets: new_visible_packets)} end defp handle_info_start_geolocation(socket) do if geolocation_enabled?() do ip_for_geolocation = if Application.get_env(:aprsme, AprsmeWeb.Endpoint)[:code_reloader] do # For testing geolocation in dev environment, use a public IP address. # This will be geolocated to Mountain View, CA. "8.8.8.8" else extract_ip(socket) end if valid_ip_for_geolocation?(ip_for_geolocation) do start_geolocation_task(ip_for_geolocation) end end {:noreply, socket} end defp geolocation_enabled? do Application.get_env(:aprsme, :disable_aprs_connection, false) != true end defp extract_ip(socket) do case socket.private[:connect_info][:peer_data][:address] do {a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}" {a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}" _ -> nil end end defp valid_ip_for_geolocation?(ip) do ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") end defp start_geolocation_task(ip) do Task.start(fn -> try do get_ip_location(ip) rescue _error -> send(self(), {:ip_location, @default_center}) end end) end # Handle replaying the next historical packet @impl true def render(assigns) do ~H""" <.error_boundary id="map-error-boundary">
<%= if @slideover_open do %>
<% end %>

APRS.me

{gettext("Navigation")}
<.navigation variant={:vertical} class="text-sm" current_user={@current_user} />
{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 from visible_packets expired_keys = socket.assigns.visible_packets |> Enum.filter(fn {_key, packet} -> not packet_within_time_threshold?(packet, threshold) end) |> Enum.map(fn {key, _} -> key end) # Only update the client if there are expired markers socket = if expired_keys == [] do socket else Enum.reduce(expired_keys, socket, fn key, acc -> push_event(acc, "remove_marker", %{id: key}) end) end # Use Map.drop/2 for better performance updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys) {:noreply, assign(socket, visible_packets: updated_visible_packets)} end defp handle_reload_historical_packets(socket) do 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 = start_progressive_historical_loading(socket) {:noreply, socket} else {:noreply, socket} end end # Check if a packet is within the time threshold (not too old) @spec packet_within_time_threshold?(struct(), any()) :: boolean() defp packet_within_time_threshold?(packet, threshold) do case packet do %{received_at: received_at} when not is_nil(received_at) -> threshold_dt = convert_threshold_to_datetime(threshold) DateTime.compare(received_at, threshold_dt) in [:gt, :eq] _ -> # If no timestamp, treat as current true end end defp convert_threshold_to_datetime(threshold) do cond do is_integer(threshold) -> # Assume seconds since epoch DateTime.from_unix!(threshold) is_binary(threshold) -> case DateTime.from_iso8601(threshold) do {:ok, dt, _} -> dt _ -> DateTime.utc_now() end match?(%DateTime{}, threshold) -> threshold true -> DateTime.utc_now() end end # Helper functions # Fetch historical packets from the database # Select the best packet to display for a callsign - prioritize position over weather defp select_best_packet_for_display(packets) do # Separate position and weather packets using the same logic as PacketUtils.weather_packet? {position_packets, weather_packets} = Enum.split_with(packets, fn packet -> # A packet is a position packet if it's NOT a weather packet not PacketUtils.weather_packet?(packet) end) # Prefer the most recent position packet, fall back to most recent weather packet case position_packets do [] -> # No position packets, use most recent weather packet hd(weather_packets) [single_position] -> # Only one position packet, use it single_position position_list -> # Multiple position packets, use most recent one Enum.max_by(position_list, & &1.received_at, DateTime) end end defp build_packet_data_list(historical_packets) do # Group by callsign and identify most recent packet for each grouped_packets = Enum.group_by(historical_packets, fn packet -> packet.sender || "unknown" end) # Batch fetch weather information for all callsigns to avoid N+1 queries callsigns = Map.keys(grouped_packets) weather_callsigns = get_weather_callsigns_batch(callsigns) # For each callsign group, find the most recent packet and mark it appropriately grouped_packets |> Enum.flat_map(fn {callsign, packets} -> # Sort by received_at to find most recent sorted_packets = Enum.sort_by(packets, & &1.received_at, {:desc, DateTime}) case sorted_packets do [] -> [] packets_list -> # Find the best packet to display as "current" - prioritize position over weather selected_packet = select_best_packet_for_display(packets_list) historical = Enum.reject(packets_list, &(&1.id == selected_packet.id)) # Always include the selected packet has_weather = MapSet.member?(weather_callsigns, String.upcase(callsign)) most_recent_data = build_minimal_packet_data(selected_packet, true, has_weather) # Get coordinates of selected packet for distance filtering {most_recent_lat, most_recent_lon, _} = MapHelpers.get_coordinates(selected_packet) # Filter historical packets that are too close to most recent position filtered_historical = if most_recent_lat && most_recent_lon do Enum.filter(historical, fn packet -> {lat, lon, _} = MapHelpers.get_coordinates(packet) if lat && lon do distance_meters = calculate_distance_meters(most_recent_lat, most_recent_lon, lat, lon) # Only show if 10+ meters away distance_meters >= 10.0 else # Skip packets without coordinates false end end) else # If most recent has no coordinates, include all historical historical end # Build data for remaining historical packets historical_data = filtered_historical |> Enum.map(fn packet -> build_minimal_packet_data(packet, false, has_weather) end) |> Enum.filter(& &1) # Combine most recent and filtered historical Enum.filter([most_recent_data | historical_data], & &1) end end) |> Enum.filter(& &1) end defp build_minimal_packet_data(packet, is_most_recent, has_weather) do # Build minimal packet data without calling expensive PacketUtils.build_packet_data {lat, lon, _} = MapHelpers.get_coordinates(packet) if lat && lon do # Use PacketUtils to get symbol information properly (includes data_extended fallback) symbol_table_id = PacketUtils.get_packet_field(packet, :symbol_table_id, "/") symbol_code = PacketUtils.get_packet_field(packet, :symbol_code, ">") # Generate symbol HTML using the SymbolRenderer symbol_html = AprsmeWeb.SymbolRenderer.render_marker_symbol( symbol_table_id, symbol_code, packet.sender || "", 32 ) %{ "id" => if(is_most_recent, do: "current_#{packet.id}", else: "hist_#{packet.id}"), "lat" => lat, "lng" => lon, "callsign" => packet.sender || "", "symbol_table_id" => symbol_table_id, "symbol_code" => symbol_code, "symbol_html" => symbol_html, "comment" => packet.comment || "", "timestamp" => DateTime.to_unix(packet.received_at || DateTime.utc_now(), :millisecond), "historical" => !is_most_recent, "is_most_recent_for_callsign" => is_most_recent, "popup" => build_simple_popup(packet, has_weather) } end end defp build_simple_popup(packet, has_weather) do # Build popup HTML directly without database queries callsign = packet.sender || "Unknown" timestamp_dt = packet.received_at || DateTime.utc_now() cache_buster = System.system_time(:millisecond) # Check if this packet itself is a weather packet is_weather = PacketUtils.weather_packet?(packet) if is_weather do # Build weather popup %{ callsign: callsign, comment: nil, timestamp_dt: timestamp_dt, cache_buster: cache_buster, weather: true, weather_link: true, temperature: PacketUtils.get_weather_field(packet, :temperature), temp_unit: "°F", humidity: PacketUtils.get_weather_field(packet, :humidity), wind_direction: PacketUtils.get_weather_field(packet, :wind_direction), wind_speed: PacketUtils.get_weather_field(packet, :wind_speed), wind_unit: "mph", wind_gust: PacketUtils.get_weather_field(packet, :wind_gust), gust_unit: "mph", pressure: PacketUtils.get_weather_field(packet, :pressure), rain_1h: PacketUtils.get_weather_field(packet, :rain_1h), rain_24h: PacketUtils.get_weather_field(packet, :rain_24h), rain_since_midnight: PacketUtils.get_weather_field(packet, :rain_since_midnight), rain_1h_unit: "in", rain_24h_unit: "in", rain_since_midnight_unit: "in" } |> PopupComponent.popup() |> Safe.to_iodata() |> IO.iodata_to_binary() else # Build standard popup %{ callsign: callsign, comment: packet.comment || "", timestamp_dt: timestamp_dt, cache_buster: cache_buster, weather: false, # Use pre-fetched weather info weather_link: has_weather } |> PopupComponent.popup() |> Safe.to_iodata() |> IO.iodata_to_binary() end end # Batch fetch weather callsigns to avoid N+1 queries defp get_weather_callsigns_batch(callsigns) when is_list(callsigns) do import Ecto.Query # Normalize callsigns normalized_callsigns = Enum.map(callsigns, &String.upcase/1) # Single query to find all callsigns that have weather packets query = from p in Aprsme.Packet, where: fragment("UPPER(?)", p.sender) in ^normalized_callsigns, where: p.data_type == "weather" or (p.symbol_table_id == "/" and p.symbol_code == "_"), select: fragment("UPPER(?)", p.sender), distinct: true weather_callsigns = Aprsme.Repo.all(query) MapSet.new(weather_callsigns) rescue _ -> MapSet.new() end # Calculate distance between two lat/lon points in meters using Haversine formula defp calculate_distance_meters(lat1, lon1, lat2, lon2) do # Convert latitude and longitude from degrees to radians lat1_rad = lat1 * :math.pi() / 180 lon1_rad = lon1 * :math.pi() / 180 lat2_rad = lat2 * :math.pi() / 180 lon2_rad = lon2 * :math.pi() / 180 # Haversine formula dlat = lat2_rad - lat1_rad dlon = lon2_rad - lon1_rad a = :math.sin(dlat / 2) * :math.sin(dlat / 2) + :math.cos(lat1_rad) * :math.cos(lat2_rad) * :math.sin(dlon / 2) * :math.sin(dlon / 2) c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) # Earth's radius in meters earth_radius_meters = 6_371_000 # Distance in meters earth_radius_meters * c end # Progressive loading functions using LiveView's efficient update mechanisms @spec start_progressive_historical_loading(Socket.t()) :: Socket.t() defp start_progressive_historical_loading(socket) do # Clear existing historical packets before loading new ones socket = push_event(socket, "clear_historical_packets", %{}) # For high zoom levels, load everything in one batch for maximum speed zoom = socket.assigns.map_zoom || 5 if zoom >= 10 do # High zoom - load everything at once for maximum speed socket |> assign(loading_batch: 0, total_batches: 1) |> load_historical_batch(0) else # Low zoom - use progressive loading to prevent overwhelming total_batches = calculate_batch_count_for_zoom(zoom) # Start with first batch socket = socket |> assign(loading_batch: 0, total_batches: total_batches) |> load_historical_batch(0) # Load all remaining batches immediately - no delays Enum.each(1..(total_batches - 1), fn batch_index -> send(self(), {:load_historical_batch, batch_index}) end) socket end end # Calculate optimal batch size based on zoom level # Higher zoom = smaller viewport = load everything at once for speed @spec calculate_batch_size_for_zoom(integer()) :: integer() # High zoom - load everything at once (up to 500 packets) defp calculate_batch_size_for_zoom(zoom) when zoom >= 10, do: 500 # Medium zoom defp calculate_batch_size_for_zoom(zoom) when zoom >= 8, do: 100 # Zoomed out defp calculate_batch_size_for_zoom(zoom) when zoom >= 5, do: 75 # Very zoomed out defp calculate_batch_size_for_zoom(_), do: 50 # Calculate optimal number of batches based on zoom level # Higher zoom = fewer batches needed since viewport is smaller @spec calculate_batch_count_for_zoom(integer()) :: integer() # Very zoomed in - fewer batches defp calculate_batch_count_for_zoom(zoom) when zoom >= 15, do: 2 # Moderately zoomed in defp calculate_batch_count_for_zoom(zoom) when zoom >= 12, do: 3 # Medium zoom defp calculate_batch_count_for_zoom(zoom) when zoom >= 8, do: 4 # Zoomed out - more batches defp calculate_batch_count_for_zoom(_), do: 5 @spec load_historical_batch(Socket.t(), integer()) :: Socket.t() defp load_historical_batch(socket, batch_offset) do if socket.assigns.map_bounds do bounds = [ socket.assigns.map_bounds.west, socket.assigns.map_bounds.south, socket.assigns.map_bounds.east, socket.assigns.map_bounds.north ] # Calculate zoom-based batch size - higher zoom = smaller batches for faster response zoom = socket.assigns.map_zoom || 5 batch_size = calculate_batch_size_for_zoom(zoom) offset = batch_offset * batch_size packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets) historical_packets = if packets_module == Aprsme.Packets do # Use cached queries for better performance # Include zoom level in cache key for better cache efficiency Aprsme.CachedQueries.get_recent_packets_cached(%{ bounds: bounds, limit: batch_size, offset: offset, zoom: zoom }) else # Fallback for testing packets_module.get_recent_packets_optimized(%{ bounds: bounds, limit: batch_size, offset: offset }) end if Enum.any?(historical_packets) do # Process this batch and send to frontend packet_data_list = build_packet_data_list(historical_packets) if Enum.any?(packet_data_list) do # Use LiveView's efficient push_event for incremental updates total_batches = socket.assigns.total_batches || 4 socket = push_event(socket, "add_historical_packets_batch", %{ packets: packet_data_list, batch: batch_offset, is_final: batch_offset >= total_batches - 1 }) # Update progress for user feedback socket = assign(socket, loading_batch: batch_offset + 1) socket else socket end else socket end else socket end end @spec within_bounds?(map() | struct(), map()) :: boolean() defp within_bounds?(packet, bounds) do {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) # Basic validation if is_nil(lat) or is_nil(lon) do false else # Check latitude bounds (straightforward) lat_in_bounds = lat >= bounds.south && lat <= bounds.north # Check longitude bounds (handle potential wrapping) lng_in_bounds = if bounds.west <= bounds.east do # Normal case: bounds don't cross antimeridian lon >= bounds.west && lon <= bounds.east else # Bounds cross antimeridian (e.g., west=170, east=-170) lon >= bounds.west || lon <= bounds.east end lat_in_bounds && lng_in_bounds end end # Get location from IP using ip-api.com @spec get_ip_location(String.t() | nil) :: {float(), float()} | nil defp get_ip_location(nil), do: nil defp get_ip_location(ip) do # Asynchronously fetch IP location case Req.get("https://ip-api.com/json/#{ip}") do {:ok, %Req.Response{status: 200, body: body}} -> handle_ip_api_response(body) {:ok, _response} -> send_default_ip_location() {:error, %{reason: :timeout}} -> send_default_ip_location() {:error, _error} -> send_default_ip_location() end end defp handle_ip_api_response(body) do case Jason.decode(body) do {:ok, %{"status" => "success", "lat" => lat, "lon" => lng}} when is_number(lat) and is_number(lng) -> if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do lat_float = lat / 1.0 lng_float = lng / 1.0 send(self(), {:ip_location, %{lat: lat_float, lng: lng_float}}) else send_default_ip_location() end {:ok, _} -> send_default_ip_location() {:error, _decode_error} -> send_default_ip_location() end end defp send_default_ip_location, do: send(self(), {:ip_location, @default_center}) @impl true def terminate(_reason, socket) do 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 :ok end # Add a helper for robust bounds comparison defp compare_bounds(b1, b2) do # Handle nil bounds cond do is_nil(b1) and is_nil(b2) -> true is_nil(b1) or is_nil(b2) -> false true -> compare_bounds_maps(b1, b2) end end defp compare_bounds_maps(b1, b2) do round4 = fn x -> case x do n when is_float(n) -> Float.round(n, 4) n when is_integer(n) -> n * 1.0 _ -> x end end Enum.all?([:north, :south, :east, :west], fn key -> round4.(Map.get(b1, key)) == round4.(Map.get(b2, key)) end) end # --- Private bounds update helpers --- @spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()} defp handle_bounds_update(bounds, socket) do # Update the map bounds from the client, converting to atom keys map_bounds = %{ north: bounds["north"], south: bounds["south"], east: bounds["east"], west: bounds["west"] } # 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 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 # Only schedule a bounds update if the bounds have actually changed (with rounding) if compare_bounds(map_bounds, socket.assigns.map_bounds) do {:noreply, socket} else # If this is the first bounds update (map_bounds is nil), process immediately # to avoid race condition with historical packet loading if is_nil(socket.assigns.map_bounds) do # Process immediately for initial bounds socket = process_bounds_update(map_bounds, socket) {:noreply, socket} else # For subsequent updates, use the timer to debounce schedule_bounds_update(map_bounds, socket) end end end defp schedule_bounds_update(map_bounds, socket) do if socket.assigns[:bounds_update_timer] do Process.cancel_timer(socket.assigns.bounds_update_timer) end timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 100) socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds) {:noreply, socket} end @spec process_bounds_update(map(), Socket.t()) :: Socket.t() defp process_bounds_update(map_bounds, socket) do # Remove out-of-bounds packets and markers immediately new_visible_packets = socket.assigns.visible_packets |> Enum.filter(fn {_k, packet} -> within_bounds?(packet, map_bounds) end) |> Map.new() packets_to_remove = socket.assigns.visible_packets |> Enum.reject(fn {_k, packet} -> within_bounds?(packet, map_bounds) end) |> Enum.map(fn {k, _} -> k end) # Remove markers for out-of-bounds packets socket = if packets_to_remove == [] do socket else Enum.reduce(packets_to_remove, socket, fn k, acc -> push_event(acc, "remove_marker", %{id: k}) end) end # Remove only out-of-bounds historical packets instead of clearing all socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds}) # Load historical packets for the new bounds # Always load historical packets when bounds change to ensure new areas have data socket = start_progressive_historical_loading(socket) # Update map bounds and visible packets assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets) end end