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, put_flash: 3] alias Aprsme.CachedQueries 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 # Memory limits to prevent unbounded growth @max_visible_packets 1000 @max_historical_packets 5000 @max_all_packets 2000 # Parse map state from URL parameters @spec parse_map_params(map()) :: {map(), integer()} defp parse_map_params(params) do lat = parse_latitude(Map.get(params, "lat")) lng = parse_longitude(Map.get(params, "lng")) zoom = parse_zoom(Map.get(params, "z")) map_center = %{lat: lat, lng: lng} {map_center, zoom} end defp parse_latitude(nil), do: @default_center.lat defp parse_latitude(lat_str) do parse_float_in_range(lat_str, @default_center.lat, -90, 90) end defp parse_longitude(nil), do: @default_center.lng defp parse_longitude(lng_str) do parse_float_in_range(lng_str, @default_center.lng, -180, 180) end defp parse_zoom(nil), do: @default_zoom defp parse_zoom(zoom_str) do parse_int_in_range(zoom_str, @default_zoom, 1, 20) end defp parse_float_in_range(str, default, min, max) do case Float.parse(str) do {val, _} when val >= min and val <= max -> val _ -> default end end defp parse_int_in_range(str, default, min, max) do case Integer.parse(str) do {val, _} when val >= min and val <= max -> val _ -> default end end # Convert various types to float defp to_float(value) when is_binary(value), do: String.to_float(value) defp to_float(value) when is_integer(value), do: value / 1.0 defp to_float(value) when is_float(value), do: value defp to_float(_), do: 0.0 @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} = 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} = handle_callsign_tracking( tracked_callsign, map_center, map_zoom, has_explicit_url_params?(params) ) # Calculate initial bounds initial_bounds = 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 Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets") Process.send_after(self(), :cleanup_old_packets, 60_000) socket 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 has_explicit_url_params?(params) do !!(params["lat"] || params["lng"] || params["z"]) end defp determine_map_location(params, session) do {url_center, url_zoom} = parse_map_params(params) has_explicit_url_params = has_explicit_url_params?(params) case session["ip_geolocation"] do %{"lat" => lat, "lng" => lng} when is_number(lat) and is_number(lng) -> if has_explicit_url_params do {url_center, url_zoom, false} else {%{lat: lat, lng: lng}, 11, true} end _ -> {url_center, url_zoom, !has_explicit_url_params} end end defp handle_callsign_tracking(tracked_callsign, map_center, map_zoom, has_explicit_url_params) do if tracked_callsign != "" and not has_explicit_url_params do case CachedQueries.get_latest_packet_for_callsign_cached(tracked_callsign) do %{lat: lat, lon: lon} when is_number(lat) and is_number(lon) -> {%{lat: lat, lng: lon}, 12} _ -> {map_center, map_zoom} end else {map_center, map_zoom} end end 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, 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: tracked_callsign != "", # Loading state management historical_loading: false, loading_generation: 0, pending_batch_tasks: [] ) 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 = calculate_degrees_per_pixel(zoom) # 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, # 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 # Ensure coordinates are floats lat_float = to_float(lat) lng_float = to_float(lng) socket = update_and_zoom_to_location(socket, lat_float, lng_float, 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 = filter_packets_by_time_and_bounds( socket.assigns.visible_packets, socket.assigns.map_bounds, socket.assigns.packet_age_threshold ) visible_packets_list = build_packet_data_list_from_map(filtered_packets, false, socket) 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 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 socket = if socket.assigns.map_center.lat == @default_center.lat and socket.assigns.map_center.lng == @default_center.lng do socket else 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 Logger.info("RF path hover start - path: #{inspect(path)}") # Parse the path to find digipeater/igate stations path_stations = parse_rf_path(path) Logger.info("Parsed path stations: #{inspect(path_stations)}") # Query for positions of path stations path_station_positions = 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, station_lng: lng, 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) do lat = extract_coordinate(center, "lat", socket.assigns.map_center.lat) lng = extract_coordinate(center, "lng", socket.assigns.map_center.lng) # Validate and clamp values lat = clamp_coordinate(lat, -90.0, 90.0) lng = clamp_coordinate(lng, -180.0, 180.0) {lat, lng} end defp extract_coordinate(map, key, default) do Map.get(map, key, default) end defp clamp_coordinate(value, min, max) do value |> max(min) |> min(max) end defp clamp_zoom(zoom) do max(1, min(20, zoom)) end # Calculate degrees per pixel based on zoom level defp calculate_degrees_per_pixel(zoom) when zoom >= 15, do: 0.000005 defp calculate_degrees_per_pixel(zoom) when zoom >= 12, do: 0.00005 defp calculate_degrees_per_pixel(zoom) when zoom >= 10, do: 0.0002 defp calculate_degrees_per_pixel(zoom) when zoom >= 8, do: 0.001 defp calculate_degrees_per_pixel(zoom) when zoom >= 6, do: 0.005 defp calculate_degrees_per_pixel(zoom) when zoom >= 4, do: 0.02 defp calculate_degrees_per_pixel(_), do: 0.1 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 (old_zoom <= 8 and new_zoom > 8) or (old_zoom > 8 and new_zoom <= 8) end defp handle_zoom_threshold_crossing(socket, zoom) do if zoom <= 8 do # Switching to heat map socket |> push_event("clear_all_markers", %{}) |> send_heat_map_for_current_bounds() else # Switching to markers trigger_marker_display(socket) end 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("", socket), do: {:noreply, socket} defp handle_callsign_search(callsign, socket) do if valid_callsign?(callsign) do {:noreply, push_navigate(socket, to: "/#{callsign}")} else {:noreply, put_flash(socket, :error, gettext("Invalid callsign format"))} end 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 # Parse trail duration with validation and bounds checking defp parse_trail_duration(duration) when is_binary(duration) do case Integer.parse(duration) do {hours, ""} when hours in [1, 6, 12, 24, 48, 168] -> hours _ -> # Default to 1 hour if invalid 1 end end defp parse_trail_duration(_), do: 1 # Parse historical hours with validation defp parse_historical_hours(hours) when is_binary(hours) do case Integer.parse(hours) do {h, ""} when h in [1, 3, 6, 12, 24] -> h _ -> # Default to 1 hour if invalid 1 end end defp parse_historical_hours(_), do: 1 @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({: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 = 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 = 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 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 with memory limit all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet) all_packets = if map_size(all_packets) > @max_all_packets do prune_oldest_packets(all_packets, @max_all_packets) else all_packets end socket = assign(socket, all_packets: all_packets) # Handle packet visibility logic socket = handle_packet_visibility(packet, lat, lon, callsign_key, socket) {:noreply, 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) socket end true -> 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) 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) # Enforce memory limits new_visible_packets = if map_size(new_visible_packets) > @max_visible_packets do prune_oldest_packets(new_visible_packets, @max_visible_packets) else new_visible_packets end socket = assign(socket, visible_packets: new_visible_packets) # Check zoom level to decide how to display the packet 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 marker_data = PacketUtils.build_packet_data(packet, true, get_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 end end # Handle replaying the next historical packet @impl true def render(assigns) do ~H""" <.error_boundary id="map-error-boundary">
<%= 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 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 = remove_markers_batch(socket, expired_keys) # 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 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 = start_progressive_historical_loading(socket) {:noreply, socket} else Logger.debug("Skipping historical reload - conditions not met") {: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) when is_integer(threshold) do # Assume seconds since epoch DateTime.from_unix!(threshold) end defp convert_threshold_to_datetime(threshold) when is_binary(threshold) do case DateTime.from_iso8601(threshold) do {:ok, dt, _} -> dt _ -> DateTime.utc_now() end end defp convert_threshold_to_datetime(%DateTime{} = threshold), do: threshold defp convert_threshold_to_datetime(_), do: DateTime.utc_now() # 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 # Include weather data in initial grouping to avoid separate query grouped_packets = Enum.group_by(historical_packets, fn packet -> packet.sender || "unknown" end) # Build weather callsign set from packets themselves (no DB query needed) weather_callsigns = build_weather_callsign_set(historical_packets) # 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, "path" => packet.path || "", "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 # Build weather callsign set from packets themselves (avoids DB query) defp build_weather_callsign_set(packets) do packets |> Enum.filter(&PacketUtils.weather_packet?/1) |> MapSet.new(fn packet -> String.upcase(packet.sender || "") end) 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 require Logger Logger.debug( "start_progressive_historical_loading called with zoom: #{socket.assigns.map_zoom}, bounds: #{inspect(socket.assigns.map_bounds)}" ) # Increment generation to invalidate any in-flight loads new_generation = socket.assigns.loading_generation + 1 # Cancel any pending batch tasks socket = cancel_pending_loads(socket) # 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 Logger.debug("High zoom (#{zoom}), loading in single batch") socket |> assign( loading_batch: 0, total_batches: 1, historical_loading: true, loading_generation: new_generation ) |> load_historical_batch(0, new_generation) 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, historical_loading: true, loading_generation: new_generation ) |> load_historical_batch(0, new_generation) # Schedule remaining batches with generation check batch_refs = Enum.map(1..(total_batches - 1), fn batch_index -> Process.send_after(self(), {:load_historical_batch, batch_index, new_generation}, batch_index * 50) end) socket = assign(socket, pending_batch_tasks: batch_refs) socket end end defp cancel_pending_loads(socket) do # Cancel any pending batch load messages Enum.each(socket.assigns.pending_batch_tasks, &Process.cancel_timer/1) assign(socket, pending_batch_tasks: []) end # Consolidated zoom-based loading parameters @spec get_loading_params_for_zoom(integer()) :: {batch_size :: integer(), batch_count :: integer()} # Very zoomed in - load everything at once, minimal batches defp get_loading_params_for_zoom(zoom) when zoom >= 15, do: {500, 2} # Moderately zoomed in defp get_loading_params_for_zoom(zoom) when zoom >= 12, do: {500, 3} # High zoom - still load a lot defp get_loading_params_for_zoom(zoom) when zoom >= 10, do: {500, 4} # Medium zoom defp get_loading_params_for_zoom(zoom) when zoom >= 8, do: {100, 4} # Zoomed out defp get_loading_params_for_zoom(zoom) when zoom >= 5, do: {75, 5} # Very zoomed out - smaller batches, more of them defp get_loading_params_for_zoom(_), do: {50, 5} @spec calculate_batch_size_for_zoom(integer()) :: integer() defp calculate_batch_size_for_zoom(zoom) do {batch_size, _} = get_loading_params_for_zoom(zoom) batch_size end @spec calculate_batch_count_for_zoom(integer()) :: integer() defp calculate_batch_count_for_zoom(zoom) do {_, batch_count} = get_loading_params_for_zoom(zoom) batch_count end @spec load_historical_batch(Socket.t(), integer(), integer() | nil) :: Socket.t() defp load_historical_batch(socket, batch_offset, generation) do # Check generation if provided if generation && generation != socket.assigns.loading_generation do # Stale request, return unchanged socket socket else do_load_historical_batch(socket, batch_offset) end end defp do_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 = try do if packets_module == Aprsme.Packets do # Use cached queries for better performance # Include zoom level in cache key for better cache efficiency params = %{ bounds: bounds, limit: batch_size, offset: offset, zoom: zoom } # Add callsign filter if tracking params = if socket.assigns.tracked_callsign == "" do params else Map.put(params, :callsign, socket.assigns.tracked_callsign) end # Use the historical_hours setting from the UI dropdown with validation historical_hours = parse_historical_hours(socket.assigns.historical_hours || "1") params = Map.put(params, :hours_back, historical_hours) Aprsme.CachedQueries.get_recent_packets_cached(params) else # Fallback for testing packets_module.get_recent_packets_optimized(%{ bounds: bounds, limit: batch_size, offset: offset }) end rescue error -> require Logger Logger.error("Error loading historical packets: #{inspect(error)}") # Return empty list and notify user send(self(), {:show_error, gettext("Failed to load historical data. Please try again.")}) [] end if Enum.any?(historical_packets) do # Process this batch and send to frontend packet_data_list = try do build_packet_data_list(historical_packets) rescue e -> require Logger Logger.error("Error building packet data list: #{inspect(e)}") [] end if Enum.any?(packet_data_list) do # Check zoom level to decide between heat map and markers total_batches = socket.assigns.total_batches || 4 is_final_batch = batch_offset >= total_batches - 1 socket = if socket.assigns.map_zoom <= 8 do # For heat maps, store historical packets and update heat map when all batches are loaded # Add packets to historical_packets assign new_historical = Enum.reduce(historical_packets, socket.assigns.historical_packets, fn packet, acc -> key = if Map.has_key?(packet, :id), do: to_string(packet.id), else: to_string(packet["id"]) Map.put(acc, key, packet) end) # Apply memory limit new_historical = if map_size(new_historical) > @max_historical_packets do prune_oldest_packets(new_historical, @max_historical_packets) else new_historical end socket = assign(socket, historical_packets: new_historical) # If this is the final batch, update the heat map if is_final_batch do send_heat_map_for_current_bounds(socket) else socket end else # Use LiveView's efficient push_event for incremental updates push_event(socket, "add_historical_packets_batch", %{ packets: packet_data_list, batch: batch_offset, is_final: is_final_batch }) end # Update progress for user feedback socket = assign(socket, loading_batch: batch_offset + 1) # Mark loading as complete if this was the final batch socket = if is_final_batch do assign(socket, historical_loading: false) else socket end # Process any pending bounds update if loading is complete if is_final_batch && socket.assigns.pending_bounds do send(self(), {:process_pending_bounds}) end 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 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) && packet_within_time_threshold?(packet, time_threshold) end) |> Map.new() 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 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 # Add a helper for robust bounds comparison defp compare_bounds(nil, nil), do: true defp compare_bounds(nil, _), do: false defp compare_bounds(_, nil), do: false defp compare_bounds(b1, b2), do: compare_bounds_maps(b1, b2) defp compare_bounds_maps(b1, b2) do Enum.all?([:north, :south, :east, :west], fn key -> round_to_4_places(Map.get(b1, key)) == round_to_4_places(Map.get(b2, key)) end) end defp round_to_4_places(n) when is_float(n), do: Float.round(n, 4) defp round_to_4_places(n) when is_integer(n), do: n * 1.0 defp round_to_4_places(x), do: x # --- 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} 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 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}, 300) 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 defp send_heat_map_for_current_bounds(socket) do # Get all packets within current bounds all_packets = Map.values(socket.assigns.visible_packets) ++ Map.values(socket.assigns.historical_packets) # Filter by bounds filtered_packets = all_packets |> filter_packets_by_bounds(socket.assigns.map_bounds) |> Enum.uniq_by(fn packet -> Map.get(packet, :id) || Map.get(packet, "id") end) send_heat_map_for_packets(socket, filtered_packets) 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 # Helper to build packet data list from a map of packets defp build_packet_data_list_from_map(packets_map, is_most_recent, socket) do locale = get_locale(socket) packets_map |> Enum.map(fn {_callsign, packet} -> PacketUtils.build_packet_data(packet, is_most_recent, locale) end) |> Enum.filter(& &1) end # Helper to get locale from socket defp get_locale(socket) do Map.get(socket.assigns, :locale, "en") end # Helper to update map center and zoom to location defp update_and_zoom_to_location(socket, lat, lng, zoom) do socket |> assign(map_center: %{lat: lat, lng: lng}, map_zoom: zoom) |> push_event("zoom_to_location", %{lat: lat, lng: lng, zoom: zoom}) end # Helper to zoom to current location defp zoom_to_current_location(socket) do push_event(socket, "zoom_to_location", %{ lat: socket.assigns.map_center.lat, lng: socket.assigns.map_center.lng, zoom: socket.assigns.map_zoom }) end defp trigger_marker_display(socket) do # Clear heat map and show markers socket = push_event(socket, "show_markers", %{}) # Re-send all visible packets as markers visible_packets_list = build_packet_data_list_from_map(socket.assigns.visible_packets, true, socket) socket = add_markers_if_any(socket, visible_packets_list) # Trigger historical packet reload for markers start_progressive_historical_loading(socket) 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)}") # 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 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 new_visible_packets = filter_packets_by_bounds(socket.assigns.visible_packets, map_bounds) packets_to_remove = reject_packets_by_bounds(socket.assigns.visible_packets, 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 map bounds FIRST so progressive loading uses the correct bounds socket = socket |> assign(map_bounds: map_bounds, visible_packets: new_visible_packets) |> 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") socket = 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 # Parse RF path string to extract digipeater/igate callsigns defp parse_rf_path(path) when is_binary(path) do # Split by comma and filter out empty strings path |> String.split(",") |> Enum.map(&String.trim/1) # Skip empty strings, TCPIP entries and qA* entries |> Enum.reject(fn callsign -> callsign == "" || String.contains?(callsign, "TCPIP") || String.starts_with?(callsign, "qA") end) |> Enum.map(fn callsign -> # Remove any asterisk (used to mark heard stations) and WIDE patterns callsign |> String.replace("*", "") |> String.trim() end) # Filter out APRS built-in beacon patterns |> Enum.reject(fn callsign -> callsign == "" || aprs_beacon?(callsign) end) |> Enum.uniq() end defp parse_rf_path(_), do: [] # Get positions of path stations from database with caching defp get_path_station_positions(callsigns, _socket) when is_list(callsigns) do # Use cached queries for better performance CachedQueries.get_path_station_positions_cached(callsigns) end defp get_path_station_positions(_, _), do: [] # Memory management helpers defp prune_oldest_packets(packets_map, max_size) when map_size(packets_map) > max_size do # Convert to list and sort by received_at timestamp sorted_packets = packets_map |> Enum.sort_by( fn {_id, packet} -> get_packet_timestamp(packet) end, {:desc, DateTime} ) |> Enum.take(max_size) |> Map.new() sorted_packets end defp prune_oldest_packets(packets_map, _max_size), do: packets_map defp get_packet_timestamp(%{received_at: timestamp}), do: timestamp defp get_packet_timestamp(%{"received_at" => timestamp}), do: timestamp defp get_packet_timestamp(_), do: DateTime.utc_now() # Validate callsign format for security defp valid_callsign?(callsign) do # APRS callsign pattern: 1-2 letters, 1 digit, 0-3 letters, optional -SSID Regex.match?(~r/^[A-Z]{1,2}\d[A-Z]{0,3}(-\d{1,2})?$/, callsign) end # Check if a callsign is an APRS built-in beacon pattern @spec aprs_beacon?(String.t()) :: boolean() defp aprs_beacon?(callsign) do # Common APRS beacon patterns to exclude patterns = [ # WIDE1, WIDE2, WIDE1-1, etc. ~r/^WIDE\d(-\d)?$/i, # TRACE patterns ~r/^TRACE\d(-\d)?$/i, # RELAY ~r/^RELAY$/i, # ECHO ~r/^ECHO$/i, # GATE ~r/^GATE$/i, # UNPROTO ~r/^UNPROTO$/i, # BEACON ~r/^BEACON$/i, # CQ ~r/^CQ$/i, # QTH ~r/^QTH$/i, # MAIL ~r/^MAIL$/i, # TEMP1, TEMP2, etc. ~r/^TEMP\d+$/i, # ID ~r/^ID$/i, # GPS ~r/^GPS$/i, # DF ~r/^DF$/i, # DGPS ~r/^DGPS$/i, # PHG patterns ~r/^PHG\d+$/i, # RNG patterns ~r/^RNG\d+$/i, # DFS patterns ~r/^DFS\d+$/i, # HOP patterns ~r/^HOP\d+(-\d)?$/i, # REGION patterns ~r/^REGION\d+(-\d)?$/i, # STATE patterns ~r/^STATE\d+(-\d)?$/i ] # Check if the callsign matches any beacon pattern Enum.any?(patterns, fn pattern -> Regex.match?(pattern, callsign) end) end end