diff --git a/lib/aprsme/cached_queries.ex b/lib/aprsme/cached_queries.ex index 2ed3d05..2af3d4a 100644 --- a/lib/aprsme/cached_queries.ex +++ b/lib/aprsme/cached_queries.ex @@ -163,6 +163,58 @@ defmodule Aprsme.CachedQueries do stats end + @doc """ + Get path station positions with caching + """ + def get_path_station_positions_cached(callsigns) when is_list(callsigns) do + cache_key = generate_cache_key("path_stations", Enum.sort(callsigns)) + + case Cachex.get(:query_cache, cache_key) do + {:ok, result} when not is_nil(result) -> + result + + _ -> + query = """ + WITH latest_positions AS ( + SELECT DISTINCT ON (sender) + sender, + lat, + lon, + received_at + FROM packets + WHERE + sender = ANY($1::text[]) + AND lat IS NOT NULL + AND lon IS NOT NULL + AND received_at > NOW() - INTERVAL '7 days' + ORDER BY sender, received_at DESC + ) + SELECT sender, lat, lon + FROM latest_positions + ORDER BY array_position($1::text[], sender) + """ + + result = + case Ecto.Adapters.SQL.query(Repo, query, [callsigns]) do + {:ok, %{rows: rows}} -> + Enum.map(rows, fn [callsign, lat, lon] -> + %{ + callsign: callsign, + lat: Decimal.to_float(lat), + lng: Decimal.to_float(lon) + } + end) + + {:error, _} -> + [] + end + + # Cache for 5 minutes + Cachex.put(:query_cache, cache_key, result, ttl: @cache_ttl_medium) + result + end + end + # Private helper functions defp generate_cache_key(prefix, data) do diff --git a/lib/aprsme_web/aprs_symbol.ex b/lib/aprsme_web/aprs_symbol.ex index a2b6882..b9a16e0 100644 --- a/lib/aprsme_web/aprs_symbol.ex +++ b/lib/aprsme_web/aprs_symbol.ex @@ -234,6 +234,27 @@ defmodule AprsmeWeb.AprsSymbol do "
..." """ def render_marker_html(symbol_table, symbol_code, callsign \\ nil, size \\ 32) do + # For symbols without callsigns, use Cachex for better caching + if is_nil(callsign) do + cache_key = "symbol_html:#{symbol_table}:#{symbol_code}:#{size}" + + case Cachex.get(:symbol_cache, cache_key) do + {:ok, html} when not is_nil(html) -> + html + + _ -> + html = generate_marker_html(symbol_table, symbol_code, nil, size) + # Cache for 1 hour since symbols don't change + Cachex.put(:symbol_cache, cache_key, html, ttl: to_timeout(hour: 1)) + html + end + else + # For symbols with callsigns, generate directly (callsigns are dynamic) + generate_marker_html(symbol_table, symbol_code, callsign, size) + end + end + + defp generate_marker_html(symbol_table, symbol_code, callsign, size) do sprite_info = get_sprite_info(symbol_table, symbol_code) # Check if this is an overlay symbol diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 0bcc09d..c2dfcc6 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -70,104 +70,135 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def mount(params, session, socket) do - require Logger + 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 if connected?(socket) do - # Subscribe to packet updates Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets") - - # Schedule periodic cleanup of old packets Process.send_after(self(), :cleanup_old_packets, 60_000) end - # Get deployment timestamp from config (set during application startup) - deployed_at = Aprsme.Release.deployed_at() - - # Show 24 hours for more symbol variety - one_hour_ago = TimeUtils.one_day_ago() - - # Parse map state from URL parameters - {url_center, url_zoom} = parse_map_params(params) - - # Check for IP geolocation in session - - # Check if URL params were explicitly provided (not just defaults) - has_explicit_url_params = !!(params["lat"] || params["lng"] || params["z"]) - - {map_center, map_zoom, should_skip_initial_url_update} = - case session["ip_geolocation"] do - %{"lat" => lat, "lng" => lng} when is_number(lat) and is_number(lng) -> - if has_explicit_url_params do - # URL params explicitly provided - use them - - {url_center, url_zoom, false} - else - # No explicit URL params - use IP geolocation - geo_center = %{lat: lat, lng: lng} - {geo_center, 11, true} - end - - _ -> - # No geolocation available, use URL params or defaults - # Skip initial URL update if no explicit params were provided - {url_center, url_zoom, !has_explicit_url_params} - end - - socket = assign_defaults(socket, one_hour_ago) - - # Initialize the flag to track if initial historical load is completed - socket = assign(socket, initial_historical_completed: false) + socket + end + defp setup_additional_subscriptions(socket) do if connected?(socket) do Endpoint.subscribe("aprs_messages") Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") end - # Check for callsign parameter - tracked_callsign = Map.get(params, "call", "") + socket + end - # If tracking a callsign and no explicit map parameters, center on that callsign - {final_map_center, final_map_zoom} = - 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} + defp has_explicit_url_params?(params) do + !!(params["lat"] || params["lng"] || params["z"]) + end - _ -> - {map_center, map_zoom} + 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 - else - {map_center, map_zoom} + + _ -> + {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 - # Calculate initial bounds based on final center and zoom level - initial_bounds = calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom) - - {:ok, - 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 != "" - )} + 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 != "" + ) end # Calculate approximate bounds based on center point and zoom level @@ -232,13 +263,9 @@ defmodule AprsmeWeb.MapLive.Index do ) end + # Handle both bounds_changed and update_bounds events @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 + def handle_event(event, %{"bounds" => bounds}, socket) when event in ["bounds_changed", "update_bounds"] do handle_bounds_update(bounds, socket) end @@ -266,10 +293,7 @@ defmodule AprsmeWeb.MapLive.Index do 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}) + socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12) {:noreply, socket} end @@ -278,19 +302,13 @@ defmodule AprsmeWeb.MapLive.Index do 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() + filter_packets_by_time_and_bounds( + socket.assigns.visible_packets, + socket.assigns.map_bounds, + socket.assigns.packet_age_threshold + ) - 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) + visible_packets_list = build_packet_data_list_from_map(filtered_packets, false, socket) socket = assign(socket, visible_packets: filtered_packets) @@ -301,13 +319,7 @@ defmodule AprsmeWeb.MapLive.Index do send_heat_map_data(socket, filtered_packets) else # Use regular markers for high zoom levels - if Enum.any?(visible_packets_list) do - socket - |> push_event("show_markers", %{}) - |> push_event("add_markers", %{markers: visible_packets_list}) - else - socket - end + add_markers_if_any(socket, visible_packets_list) end {:noreply, socket} @@ -327,11 +339,7 @@ defmodule AprsmeWeb.MapLive.Index do socket.assigns.map_center.lng == @default_center.lng do socket else - push_event(socket, "zoom_to_location", %{ - lat: socket.assigns.map_center.lat, - lng: socket.assigns.map_center.lng, - zoom: socket.assigns.map_zoom - }) + zoom_to_current_location(socket) end # Wait for JavaScript to send the actual map bounds before loading historical packets @@ -495,7 +503,24 @@ defmodule AprsmeWeb.MapLive.Index do Logger.debug("update_map_state event received: center=#{inspect(center)}, zoom=#{zoom}") - # Parse center coordinates + # 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 + + defp parse_center_coordinates(center, socket) do lat = case center do %{"lat" => lat_val} -> lat_val @@ -511,76 +536,85 @@ defmodule AprsmeWeb.MapLive.Index do # 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} + {lat, lng} + end - # Check if we're crossing the heat map/marker threshold + defp clamp_zoom(zoom) do + max(1, min(20, zoom)) + end + + defp update_map_state(socket, map_center, zoom) do old_zoom = socket.assigns.map_zoom - crossing_threshold = (old_zoom <= 8 and zoom > 8) or (old_zoom > 8 and zoom <= 8) + crossing_threshold = is_crossing_zoom_threshold?(old_zoom, zoom) - # Update socket state socket = assign(socket, map_center: map_center, map_zoom: zoom) - # If crossing threshold, trigger appropriate display mode - socket = - if crossing_threshold do - if zoom <= 8 do - # Switching to heat map - socket = push_event(socket, "clear_all_markers", %{}) - send_heat_map_for_current_bounds(socket) - else - # Switching to markers - trigger_marker_display(socket) + if crossing_threshold do + handle_zoom_threshold_crossing(socket, zoom) + else + socket + end + end + + defp is_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 - else + socket - end - # Update URL without page reload, but skip on initial load if requested - socket = - if socket.assigns[:should_skip_initial_url_update] && !socket.assigns[:initial_bounds_loaded] do - # Skip URL update on initial load - Logger.debug("Skipping URL update on initial load") - # Clear the flag after first update - assign(socket, should_skip_initial_url_update: false) - else - new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}" - Logger.debug("Updating URL to: #{new_path}") - push_patch(socket, to: new_path, replace: true) - end + _ -> + socket + end + end - # If bounds are included, also process bounds update - socket = - case Map.get(params, "bounds") do - %{"north" => north, "south" => south, "east" => east, "west" => west} -> - map_bounds = %{ - north: north, - south: south, - east: east, - west: west - } - - # Trigger bounds processing if bounds changed OR if this is the initial load OR if we need initial historical load - if socket.assigns.map_bounds != map_bounds or - !socket.assigns[:initial_bounds_loaded] or - socket.assigns[:needs_initial_historical_load] do - require Logger - - Logger.debug( - "Sending bounds update (initial_load: #{!socket.assigns[:initial_bounds_loaded]}, needs_historical: #{socket.assigns[:needs_initial_historical_load]}): #{inspect(map_bounds)}" - ) - - send(self(), {:process_bounds_update, map_bounds}) - end - - socket - - _ -> - socket - end - - {:noreply, socket} + 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 @impl true @@ -801,8 +835,7 @@ defmodule AprsmeWeb.MapLive.Index do send_heat_map_for_current_bounds(socket) else # We're in marker mode - send individual marker - locale = Map.get(socket.assigns, :locale, "en") - marker_data = PacketUtils.build_packet_data(packet, true, locale) + 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 @@ -1321,14 +1354,7 @@ defmodule AprsmeWeb.MapLive.Index do |> 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 + 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) @@ -1420,15 +1446,14 @@ defmodule AprsmeWeb.MapLive.Index do end defp build_packet_data_list(historical_packets) do - # Group by callsign and identify most recent packet for each + # Include weather data in initial grouping to avoid separate query 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) + # 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 @@ -1575,27 +1600,11 @@ defmodule AprsmeWeb.MapLive.Index do 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: - not is_nil(p.temperature) or not is_nil(p.humidity) or not is_nil(p.pressure) or - not is_nil(p.wind_speed) or not is_nil(p.wind_direction) or not is_nil(p.rain_1h), - select: fragment("UPPER(?)", p.sender), - distinct: true - - weather_callsigns = Aprsme.Repo.all(query) - MapSet.new(weather_callsigns) - rescue - _ -> MapSet.new() + # 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 @@ -1665,29 +1674,36 @@ defmodule AprsmeWeb.MapLive.Index do 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 + # Consolidated zoom-based loading parameters + @spec get_loading_params_for_zoom(integer()) :: {batch_size :: integer(), batch_count :: integer()} + defp get_loading_params_for_zoom(zoom) do + cond do + # Very zoomed in - load everything at once, minimal batches + zoom >= 15 -> {500, 2} + # Moderately zoomed in + zoom >= 12 -> {500, 3} + # High zoom - still load a lot + zoom >= 10 -> {500, 4} + # Medium zoom + zoom >= 8 -> {100, 4} + # Zoomed out + zoom >= 5 -> {75, 5} + # Very zoomed out - smaller batches, more of them + true -> {50, 5} + end + end + + @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 - # 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 + 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()) :: Socket.t() defp load_historical_batch(socket, batch_offset) do @@ -1833,6 +1849,55 @@ defmodule AprsmeWeb.MapLive.Index do end 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) @@ -1920,16 +1985,7 @@ defmodule AprsmeWeb.MapLive.Index do defp send_heat_map_data(socket, filtered_packets) do # Convert map of packets to list packet_list = Map.values(filtered_packets) - - # Get clustering data - case Clustering.cluster_packets(packet_list, 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 + send_heat_map_for_packets(socket, packet_list) end defp send_heat_map_for_current_bounds(socket) do @@ -1941,41 +1997,78 @@ defmodule AprsmeWeb.MapLive.Index do # Filter by bounds filtered_packets = all_packets - |> Enum.filter(&within_bounds?(&1, socket.assigns.map_bounds)) + |> 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(filtered_packets, socket.assigns.map_zoom) do + 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 + + # Helper function to parse coordinates from various formats + @spec parse_coordinate(any()) :: float() + defp parse_coordinate(coord) do + cond do + is_binary(coord) -> String.to_float(coord) + is_integer(coord) -> coord / 1.0 + is_float(coord) -> coord + true -> 0.0 + end + 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 - locale = Map.get(socket.assigns, :locale, "en") + visible_packets_list = build_packet_data_list_from_map(socket.assigns.visible_packets, true, socket) - visible_packets_list = - socket.assigns.visible_packets - |> Enum.map(fn {_callsign, packet} -> - PacketUtils.build_packet_data(packet, true, locale) - end) - |> Enum.filter(& &1) - - socket = - if Enum.any?(visible_packets_list) do - push_event(socket, "add_markers", %{markers: visible_packets_list}) - else - socket - end + socket = add_markers_if_any(socket, visible_packets_list) # Trigger historical packet reload for markers start_progressive_historical_loading(socket) @@ -1999,25 +2092,11 @@ defmodule AprsmeWeb.MapLive.Index 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) + 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 = - 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 + socket = remove_markers_batch(socket, packets_to_remove) # Only clear historical packets if: # 1. Bounds actually changed AND @@ -2079,49 +2158,10 @@ defmodule AprsmeWeb.MapLive.Index do defp parse_rf_path(_), do: [] - # Get positions of path stations from database - defp get_path_station_positions(callsigns, socket) when is_list(callsigns) do - # Query for the most recent position of each station - # Show complete RF path regardless of map bounds - only the originating station needs to be visible - _bounds = socket.assigns.map_bounds - - query = """ - WITH latest_positions AS ( - SELECT DISTINCT ON (sender) - sender, - lat, - lon, - received_at - FROM packets - WHERE - sender = ANY($1::text[]) - AND lat IS NOT NULL - AND lon IS NOT NULL - AND received_at > NOW() - INTERVAL '7 days' - ORDER BY sender, received_at DESC - ) - SELECT sender, lat, lon - FROM latest_positions - ORDER BY array_position($1::text[], sender) - """ - - case Ecto.Adapters.SQL.query( - Aprsme.Repo, - query, - [callsigns] - ) do - {:ok, %{rows: rows}} -> - Enum.map(rows, fn [callsign, lat, lon] -> - %{ - callsign: callsign, - lat: Decimal.to_float(lat), - lng: Decimal.to_float(lon) - } - end) - - {:error, _} -> - [] - end + # 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: [] diff --git a/test/aprsme_web/live/map_live/rf_path_test.exs b/test/aprsme_web/live/map_live/rf_path_test.exs index 42db171..d0173ff 100644 --- a/test/aprsme_web/live/map_live/rf_path_test.exs +++ b/test/aprsme_web/live/map_live/rf_path_test.exs @@ -1,10 +1,9 @@ defmodule AprsmeWeb.MapLive.RfPathTest do use AprsmeWeb.ConnCase + import AprsmeWeb.TestHelpers import Phoenix.LiveViewTest - alias Aprsme.Packet - alias Aprsme.Repo alias AprsmeWeb.MapLive.Index describe "RF path parsing" do @@ -13,15 +12,12 @@ defmodule AprsmeWeb.MapLive.RfPathTest do # Since it's a private function, we test the behavior {:ok, _digi1} = - Repo.insert(%Packet{ + create_test_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("33.1000"), lon: Decimal.new("-96.6000"), - has_position: true, - received_at: DateTime.truncate(DateTime.utc_now(), :second), - data_type: "position", symbol_table_id: "#", symbol_code: "r" }) @@ -44,27 +40,21 @@ defmodule AprsmeWeb.MapLive.RfPathTest do test "parses complex RF paths with multiple stations", %{conn: conn} do # Create multiple stations that could be in the path {:ok, _station1} = - Repo.insert(%Packet{ + create_test_packet(%{ sender: "N5ABC", base_callsign: "N5ABC", ssid: nil, lat: Decimal.new("33.2000"), - lon: Decimal.new("-96.6000"), - has_position: true, - received_at: DateTime.truncate(DateTime.utc_now(), :second), - data_type: "position" + lon: Decimal.new("-96.6000") }) {:ok, _station2} = - Repo.insert(%Packet{ + create_test_packet(%{ sender: "WB5DEF-1", base_callsign: "WB5DEF", ssid: "1", lat: Decimal.new("33.3000"), - lon: Decimal.new("-96.7000"), - has_position: true, - received_at: DateTime.truncate(DateTime.utc_now(), :second), - data_type: "position" + lon: Decimal.new("-96.7000") }) {:ok, view, _html} = live(conn, "/") @@ -230,12 +220,7 @@ defmodule AprsmeWeb.MapLive.RfPathTest do {:ok, view, _html} = live(conn, "/") # Set map bounds to a small area around Texas that excludes the outside station - bounds = %{ - "north" => "33.0", - "south" => "32.0", - "east" => "-96.0", - "west" => "-97.0" - } + bounds = texas_bounds() render_hook(view, "bounds_changed", %{"bounds" => bounds}) @@ -257,12 +242,7 @@ defmodule AprsmeWeb.MapLive.RfPathTest do {:ok, view, _html} = live(conn, "/") # Set very restrictive bounds that exclude both stations - bounds = %{ - "north" => "31.0", - "south" => "30.0", - "east" => "-95.0", - "west" => "-96.0" - } + bounds = restrictive_bounds() render_hook(view, "bounds_changed", %{"bounds" => bounds}) diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex new file mode 100644 index 0000000..9448076 --- /dev/null +++ b/test/support/test_helpers.ex @@ -0,0 +1,81 @@ +defmodule AprsmeWeb.TestHelpers do + @moduledoc """ + Common test helper functions to reduce duplication across test files. + """ + + alias Aprsme.Packet + alias Aprsme.Repo + + @doc """ + Creates a test packet with default values that can be overridden. + """ + def create_test_packet(attrs \\ %{}) do + default_attrs = %{ + sender: "TEST-1", + base_callsign: "TEST", + ssid: "1", + lat: Decimal.new("33.0000"), + lon: Decimal.new("-96.0000"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position" + } + + attrs = Map.merge(default_attrs, attrs) + + Repo.insert(%Packet{ + sender: attrs.sender, + base_callsign: attrs.base_callsign, + ssid: attrs.ssid, + lat: attrs.lat, + lon: attrs.lon, + has_position: attrs.has_position, + received_at: attrs.received_at, + data_type: attrs.data_type, + symbol_table_id: Map.get(attrs, :symbol_table_id), + symbol_code: Map.get(attrs, :symbol_code), + temperature: Map.get(attrs, :temperature), + humidity: Map.get(attrs, :humidity), + wind_speed: Map.get(attrs, :wind_speed) + }) + end + + @doc """ + Creates common test bounds for Texas area. + """ + def texas_bounds do + %{ + "north" => "33.0", + "south" => "32.0", + "east" => "-96.0", + "west" => "-97.0" + } + end + + @doc """ + Creates common test bounds for a restrictive area. + """ + def restrictive_bounds do + %{ + "north" => "31.0", + "south" => "30.0", + "east" => "-95.0", + "west" => "-96.0" + } + end + + @doc """ + Common time calculations used across tests. + """ + def hours_ago(hours) when is_number(hours) do + DateTime.add(DateTime.utc_now(), -hours * 3600, :second) + end + + def minutes_ago(minutes) when is_number(minutes) do + DateTime.add(DateTime.utc_now(), -minutes * 60, :second) + end + + def days_ago(days) when is_number(days) do + DateTime.add(DateTime.utc_now(), -days * 86_400, :second) + end +end diff --git a/vendor/aprs b/vendor/aprs index b964e1d..80589d4 160000 --- a/vendor/aprs +++ b/vendor/aprs @@ -1 +1 @@ -Subproject commit b964e1da6e159900d01416c178a2cd20a721447e +Subproject commit 80589d4ad745c795830c0bb96e485d69ce69011a