diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 0b4e911..f903eac 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -6,7 +6,7 @@ defmodule AprsmeWeb.MapLive.Index do 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] + import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2, put_flash: 3] alias Aprsme.CachedQueries alias Aprsme.GeoUtils @@ -22,52 +22,60 @@ defmodule AprsmeWeb.MapLive.Index do @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 - # 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 + 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) @@ -114,24 +122,30 @@ defmodule AprsmeWeb.MapLive.Index do end defp setup_subscriptions(socket) do - if connected?(socket) do - Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") - Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets") - Process.send_after(self(), :cleanup_old_packets, 60_000) - end + 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 - if connected?(socket) do - Endpoint.subscribe("aprs_messages") - Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") - end + 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 @@ -197,7 +211,11 @@ defmodule AprsmeWeb.MapLive.Index do all_packets: %{}, station_popup_open: false, initial_bounds_loaded: false, - needs_initial_historical_load: tracked_callsign != "" + needs_initial_historical_load: tracked_callsign != "", + # Loading state management + historical_loading: false, + loading_generation: 0, + pending_batch_tasks: [] ) end @@ -207,16 +225,7 @@ defmodule AprsmeWeb.MapLive.Index do 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 + degrees_per_pixel = calculate_degrees_per_pixel(zoom) # Assume viewport is roughly 800x600 pixels # Half of 600px height @@ -254,6 +263,10 @@ defmodule AprsmeWeb.MapLive.Index do 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", @@ -279,19 +292,8 @@ defmodule AprsmeWeb.MapLive.Index do 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 + lat_float = to_float(lat) + lng_float = to_float(lng) socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12) @@ -453,14 +455,10 @@ defmodule AprsmeWeb.MapLive.Index do @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 + callsign + |> String.trim() + |> String.upcase() + |> handle_callsign_search(socket) end @impl true @@ -520,30 +518,55 @@ defmodule AprsmeWeb.MapLive.Index do {:noreply, socket} end - defp parse_center_coordinates(center, socket) do - lat = - case center do - %{"lat" => lat_val} -> lat_val - _ -> socket.assigns.map_center.lat - 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 - lng = - case center do - %{"lng" => lng_val} -> lng_val - _ -> socket.assigns.map_center.lng - end + 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 = max(-90.0, min(90.0, lat)) - lng = max(-180.0, min(180.0, lng)) + 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) @@ -617,21 +640,14 @@ defmodule AprsmeWeb.MapLive.Index do socket.assigns[:needs_initial_historical_load] 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 + defp handle_callsign_search("", socket), do: {:noreply, socket} - 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} + 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 @@ -676,14 +692,42 @@ defmodule AprsmeWeb.MapLive.Index do 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 - socket = load_historical_batch(socket, batch_offset) + # 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 @@ -750,8 +794,16 @@ defmodule AprsmeWeb.MapLive.Index do {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) callsign_key = get_callsign_key(packet) - # Update all_packets + # 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 @@ -826,6 +878,15 @@ defmodule AprsmeWeb.MapLive.Index do 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 @@ -1006,6 +1067,8 @@ defmodule AprsmeWeb.MapLive.Index do phx-update="ignore" data-center={Jason.encode!(@map_center)} data-zoom={@map_zoom} + role="application" + aria-label={gettext("APRS packet map showing real-time amateur radio stations")} > @@ -1068,6 +1131,20 @@ defmodule AprsmeWeb.MapLive.Index do <% end %> + + <%= if @historical_loading do %> +