diff --git a/lib/aprsme/encoding_utils.ex b/lib/aprsme/encoding_utils.ex index 460c994..6a5af5f 100644 --- a/lib/aprsme/encoding_utils.ex +++ b/lib/aprsme/encoding_utils.ex @@ -101,21 +101,19 @@ defmodule Aprsme.EncodingUtils do """ @spec to_float(any()) :: float() | nil def to_float(value) when is_float(value) do - if finite_float?(value), do: value, else: nil + if finite_float?(value), do: value end def to_float(value) when is_integer(value) do # Protect against integer overflow when converting to float if value >= -9.0e15 and value <= 9.0e15 do value * 1.0 - else - nil end end def to_float(%Decimal{} = value) do float = Decimal.to_float(value) - if finite_float?(float), do: float, else: nil + if finite_float?(float), do: float end def to_float(value) when is_binary(value) do @@ -129,7 +127,7 @@ defmodule Aprsme.EncodingUtils do case Float.parse(sanitized) do {float, _} when is_float(float) -> - if finite_float?(float), do: float, else: nil + if finite_float?(float), do: float :error -> nil diff --git a/lib/aprsme_web/live/map_live/bounds_manager.ex b/lib/aprsme_web/live/map_live/bounds_manager.ex new file mode 100644 index 0000000..c991cfa --- /dev/null +++ b/lib/aprsme_web/live/map_live/bounds_manager.ex @@ -0,0 +1,24 @@ +defmodule AprsmeWeb.MapLive.BoundsManager do + @moduledoc """ + Handles map bounds calculations, validation, and filtering operations. + """ + + alias AprsmeWeb.Live.Shared.BoundsUtils + + # Delegate to shared utilities + defdelegate calculate_bounds_from_center_and_zoom(center, zoom), to: BoundsUtils + defdelegate valid_bounds?(map_bounds), to: BoundsUtils + defdelegate compare_bounds(b1, b2), to: BoundsUtils + defdelegate within_bounds?(packet, bounds), to: BoundsUtils + defdelegate filter_packets_by_bounds(packets, bounds), to: BoundsUtils + defdelegate reject_packets_by_bounds(packets_map, bounds), to: BoundsUtils + + @doc """ + Filter packets by both time threshold and bounds. + """ + @spec filter_packets_by_time_and_bounds(map(), map(), DateTime.t()) :: map() + def filter_packets_by_time_and_bounds(packets, bounds, time_threshold) do + # Use shared packet utils for this functionality + AprsmeWeb.Live.Shared.PacketUtils.filter_packets_by_time_and_bounds(packets, bounds, time_threshold) + end +end diff --git a/lib/aprsme_web/live/map_live/data_builder.ex b/lib/aprsme_web/live/map_live/data_builder.ex new file mode 100644 index 0000000..5b72e1e --- /dev/null +++ b/lib/aprsme_web/live/map_live/data_builder.ex @@ -0,0 +1,570 @@ +defmodule AprsmeWeb.MapLive.DataBuilder do + @moduledoc """ + Centralizes all packet data building logic for map display. + + This module consolidates data building functions that were previously + scattered across multiple modules (index.ex, display_manager.ex, + packet_utils.ex, and historical_loader.ex). + + The refactoring eliminates code duplication and provides a single + source of truth for: + - Building packet data for real-time display + - Building minimal packet data for historical display + - Creating popup content for both weather and standard packets + - Converting packet data structures for frontend consumption + + All functions handle both real-time and historical packet data with + proper coordinate validation, symbol rendering, and weather data processing. + """ + + alias AprsmeWeb.Live.Shared.CoordinateUtils + alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils + alias AprsmeWeb.Live.Shared.ParamUtils + alias AprsmeWeb.MapLive.MapHelpers + alias AprsmeWeb.MapLive.PopupComponent + alias AprsmeWeb.TimeHelpers + alias Phoenix.HTML.Safe + alias Phoenix.LiveView.Socket + + @doc """ + Build packet data list from a map of packets. + Replaces the duplicated function in both index.ex and display_manager.ex. + """ + @spec build_packet_data_list_from_map(map(), boolean(), Socket.t()) :: list() + def build_packet_data_list_from_map(packets_map, is_most_recent, socket) do + locale = get_locale(socket) + + packets_map + |> Enum.map(fn {_callsign, packet} -> + build_packet_data(packet, is_most_recent, locale) + end) + |> Enum.filter(& &1) + end + + @doc """ + Build packet data for display on the map. + Main function moved from packet_utils.ex. + """ + @spec build_packet_data(map(), boolean(), String.t()) :: map() | nil + def build_packet_data(packet, is_most_recent_for_callsign, locale) when is_boolean(is_most_recent_for_callsign) do + {lat, lon, data_extended} = MapHelpers.get_coordinates(packet) + callsign = generate_callsign(packet) + + if lat && lon && callsign != "" && callsign != nil do + packet_data = build_packet_map(packet, lat, lon, data_extended, locale) + Map.put(packet_data, "is_most_recent_for_callsign", is_most_recent_for_callsign) + end + end + + @doc """ + Build packet data with default locale. + """ + @spec build_packet_data(map(), boolean()) :: map() | nil + def build_packet_data(packet, is_most_recent_for_callsign) do + build_packet_data(packet, is_most_recent_for_callsign, "en") + end + + @doc """ + Build packet data with default parameters. + """ + @spec build_packet_data(map()) :: map() | nil + def build_packet_data(packet) do + build_packet_data(packet, false, "en") + end + + @doc """ + Build minimal packet data for historical display. + Moved from historical_loader.ex. + """ + @spec build_minimal_packet_data(map(), boolean(), boolean()) :: map() | nil + def build_minimal_packet_data(packet, is_most_recent, has_weather) do + # Build minimal packet data without calling expensive build_packet_data + {lat, lon, _} = get_coordinates(packet) + + if lat && lon do + # Use get_packet_field to get symbol information properly (includes data_extended fallback) + symbol_table_id = get_packet_field(packet, :symbol_table_id, "/") + symbol_code = get_packet_field(packet, :symbol_code, ">") + + # Generate symbol HTML using the SymbolRenderer + symbol_html = + AprsmeWeb.SymbolRenderer.render_marker_symbol( + symbol_table_id, + symbol_code, + get_packet_field(packet, :sender, ""), + 32 + ) + + %{ + "id" => if(is_most_recent, do: "current_#{get_packet_id(packet)}", else: "hist_#{get_packet_id(packet)}"), + "lat" => lat, + "lng" => lon, + "callsign" => get_packet_field(packet, :sender, ""), + "symbol_table_id" => symbol_table_id, + "symbol_code" => symbol_code, + "symbol_html" => symbol_html, + "comment" => get_packet_field(packet, :comment, ""), + "timestamp" => get_packet_timestamp_unix(packet), + "historical" => !is_most_recent, + "is_most_recent_for_callsign" => is_most_recent, + "path" => get_packet_field(packet, :path, ""), + "popup" => build_simple_popup(packet, has_weather) + } + end + end + + @doc """ + Build packet data list for historical display. + Moved from historical_loader.ex. + """ + @spec build_packet_data_list(list()) :: list() + def 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 -> + get_packet_field(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, &get_packet_received_at(&1), {: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, &(get_packet_id(&1) == get_packet_id(selected_packet))) + + # 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, _} = 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, _} = get_coordinates(packet) + + if lat && lon do + distance_meters = CoordinateUtils.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 + + @doc """ + Build simple popup content for historical display. + Moved from historical_loader.ex. + """ + @spec build_simple_popup(map(), boolean()) :: String.t() + def build_simple_popup(packet, has_weather) do + # Build popup HTML directly without database queries + callsign = get_packet_field(packet, :sender, "Unknown") + timestamp_dt = get_packet_received_at(packet) || DateTime.utc_now() + cache_buster = System.system_time(:millisecond) + + # Check if this packet itself is a weather packet + is_weather = 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: get_weather_field(packet, :temperature), + temp_unit: "°F", + humidity: get_weather_field(packet, :humidity), + wind_direction: get_weather_field(packet, :wind_direction), + wind_speed: get_weather_field(packet, :wind_speed), + wind_unit: "mph", + wind_gust: get_weather_field(packet, :wind_gust), + gust_unit: "mph", + pressure: get_weather_field(packet, :pressure), + rain_1h: get_weather_field(packet, :rain_1h), + rain_24h: get_weather_field(packet, :rain_24h), + rain_since_midnight: 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: get_packet_field(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 + + @doc """ + Select the best packet to display for a callsign. + Moved from historical_loader.ex. + """ + @spec select_best_packet_for_display(list()) :: map() + def select_best_packet_for_display(packets) do + # Separate position and weather packets using the same logic as 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 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, &get_packet_received_at(&1), DateTime) + end + end + + @doc """ + Build weather callsign set from packets. + Moved from historical_loader.ex. + """ + @spec build_weather_callsign_set(list()) :: MapSet.t() + def build_weather_callsign_set(packets) do + packets + |> Enum.filter(&weather_packet?/1) + |> MapSet.new(fn packet -> String.upcase(get_packet_field(packet, :sender, "")) end) + end + + # Private functions moved from packet_utils.ex + + @spec build_packet_map(map(), float(), float(), map(), String.t()) :: map() + defp build_packet_map(packet, lat, lon, data_extended, locale) do + data_extended = data_extended || %{} + packet_info = extract_packet_info(packet, data_extended) + popup = build_popup_content(packet, packet_info, lat, lon, locale) + + build_packet_result(packet, packet_info, lat, lon, popup) + end + + @spec extract_packet_info(map(), map()) :: map() + defp extract_packet_info(packet, data_extended) do + %{ + callsign: get_packet_field(packet, :sender, ""), + symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"), + symbol_code: get_packet_field(packet, :symbol_code, ">"), + timestamp: get_timestamp(packet), + comment: get_packet_field(packet, :comment, ""), + safe_data_extended: convert_tuples_to_strings(data_extended), + is_weather_packet: weather_packet?(packet) + } + end + + @spec build_popup_content(map(), map(), float(), float(), String.t()) :: String.t() + defp build_popup_content(packet, packet_info, lat, lon, locale) do + if packet_info.is_weather_packet do + build_weather_popup_html(packet, packet_info.callsign, locale) + else + build_standard_popup_html(packet_info, lat, lon) + end + end + + @spec build_standard_popup_html(map(), float(), float()) :: String.t() + defp build_standard_popup_html(packet_info, _lat, _lon) do + timestamp_dt = TimeHelpers.to_datetime(packet_info.timestamp) + cache_buster = System.system_time(:millisecond) + weather_link = has_weather_packets?(packet_info.callsign) + + %{ + callsign: packet_info.callsign, + comment: packet_info.comment, + timestamp_dt: timestamp_dt, + cache_buster: cache_buster, + weather: false, + weather_link: weather_link + } + |> PopupComponent.popup() + |> Safe.to_iodata() + |> IO.iodata_to_binary() + end + + @spec build_weather_popup_html(map(), String.t(), String.t()) :: String.t() + defp build_weather_popup_html(packet, sender, locale) do + received_at = get_received_at(packet) + timestamp_dt = TimeHelpers.to_datetime(received_at) + cache_buster = System.system_time(:millisecond) + + # Get weather data and convert units based on locale + temperature_raw = get_weather_field(packet, :temperature) + wind_speed_raw = get_weather_field(packet, :wind_speed) + wind_gust_raw = get_weather_field(packet, :wind_gust) + rain_1h_raw = get_weather_field(packet, :rain_1h) + rain_24h_raw = get_weather_field(packet, :rain_24h) + rain_since_midnight_raw = get_weather_field(packet, :rain_since_midnight) + + # Convert units if the values are numbers + {temperature, temp_unit} = convert_temperature(temperature_raw, locale) + {wind_speed, wind_unit} = convert_wind_speed(wind_speed_raw, locale) + {wind_gust, gust_unit} = convert_wind_speed(wind_gust_raw, locale) + {rain_1h, rain_1h_unit} = convert_rain(rain_1h_raw, locale) + {rain_24h, rain_24h_unit} = convert_rain(rain_24h_raw, locale) + {rain_since_midnight, rain_since_midnight_unit} = convert_rain(rain_since_midnight_raw, locale) + + %{ + callsign: sender, + comment: nil, + timestamp_dt: timestamp_dt, + cache_buster: cache_buster, + weather: true, + weather_link: true, + temperature: temperature, + temp_unit: temp_unit, + humidity: get_weather_field(packet, :humidity), + wind_direction: get_weather_field(packet, :wind_direction), + wind_speed: wind_speed, + wind_unit: wind_unit, + wind_gust: wind_gust, + gust_unit: gust_unit, + pressure: get_weather_field(packet, :pressure), + rain_1h: rain_1h, + rain_24h: rain_24h, + rain_since_midnight: rain_since_midnight, + rain_1h_unit: rain_1h_unit, + rain_24h_unit: rain_24h_unit, + rain_since_midnight_unit: rain_since_midnight_unit + } + |> PopupComponent.popup() + |> Safe.to_iodata() + |> IO.iodata_to_binary() + end + + @spec build_packet_result(map(), map(), float(), float(), String.t()) :: map() + defp build_packet_result(packet, packet_info, lat, lon, popup) do + # Generate unique ID for live packets to prevent conflicts + packet_id = + if Map.has_key?(packet, :id) do + "live_#{packet.id}_#{System.unique_integer([:positive])}" + else + "live_#{packet_info.callsign}_#{System.unique_integer([:positive])}" + end + + # Generate symbol HTML using the server-side renderer + symbol_html = + AprsmeWeb.SymbolRenderer.render_marker_symbol( + packet_info.symbol_table_id, + packet_info.symbol_code, + packet_info.callsign, + 32 + ) + + path_value = get_packet_field(packet, :path, "") + + %{ + "id" => packet_id, + "callsign" => packet_info.callsign, + "base_callsign" => get_packet_field(packet, :base_callsign, ""), + "ssid" => get_packet_field(packet, :ssid, nil), + "lat" => to_float(lat), + "lng" => to_float(lon), + "data_type" => to_string(get_packet_field(packet, :data_type, "unknown")), + "path" => path_value, + "comment" => packet_info.comment, + "data_extended" => packet_info.safe_data_extended || %{}, + "symbol_table_id" => packet_info.symbol_table_id, + "symbol_code" => packet_info.symbol_code, + "symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}", + "timestamp" => packet_info.timestamp, + "popup" => popup, + "symbol_html" => symbol_html + } + end + + # Utility functions for packet field access + + @spec get_packet_field(map(), atom() | String.t(), any()) :: any() + defp get_packet_field(packet, field, default) do + SharedPacketUtils.get_packet_field(packet, field, default) + end + + @spec get_timestamp(map()) :: String.t() + defp get_timestamp(packet) do + SharedPacketUtils.get_timestamp(packet) + end + + @spec to_float(any()) :: float() + defp to_float(value) do + ParamUtils.to_float(value) + end + + @spec generate_callsign(map()) :: String.t() + defp generate_callsign(packet) do + SharedPacketUtils.generate_callsign(packet) + end + + @spec weather_packet?(map()) :: boolean() + defp weather_packet?(packet) do + SharedPacketUtils.weather_packet?(packet) + end + + @spec has_weather_packets?(String.t()) :: boolean() + defp has_weather_packets?(callsign) when is_binary(callsign) do + # Use cached query for better performance + Aprsme.CachedQueries.has_weather_packets_cached?(callsign) + rescue + _ -> false + end + + defp has_weather_packets?(_), do: false + + @spec convert_tuples_to_strings(any()) :: any() + defp convert_tuples_to_strings(map) when is_map(map) do + if Map.has_key?(map, :__struct__) do + map + else + Map.new(map, fn {k, v} -> + {k, convert_tuples_to_strings(v)} + end) + end + end + + defp convert_tuples_to_strings(list) when is_list(list) do + Enum.map(list, &convert_tuples_to_strings/1) + end + + defp convert_tuples_to_strings(tuple) when is_tuple(tuple) do + to_string(inspect(tuple)) + end + + defp convert_tuples_to_strings(other), do: other + + @spec get_weather_field(map(), atom()) :: String.t() + defp get_weather_field(packet, key) do + case SharedPacketUtils.get_weather_field(packet, key) do + nil -> "N/A" + value -> value + end + end + + @spec get_received_at(map()) :: DateTime.t() | nil + defp get_received_at(packet) do + cond do + Map.has_key?(packet, :received_at) -> packet.received_at + Map.has_key?(packet, "received_at") -> packet["received_at"] + true -> nil + end + end + + # Helper to get locale from socket + @spec get_locale(Socket.t()) :: String.t() + defp get_locale(socket) do + Map.get(socket.assigns, :locale, "en") + end + + # Helper to get coordinates from packet + @spec get_coordinates(map()) :: {float() | nil, float() | nil, map()} + defp get_coordinates(packet) do + CoordinateUtils.get_coordinates(packet) + end + + # Helper to get packet ID + @spec get_packet_id(map()) :: any() + defp get_packet_id(packet) do + Map.get(packet, :id) || Map.get(packet, "id") + end + + # Helper to get packet received_at datetime + @spec get_packet_received_at(map()) :: DateTime.t() + defp get_packet_received_at(packet) do + get_received_at(packet) || DateTime.utc_now() + end + + # Helper to get packet timestamp as unix milliseconds + @spec get_packet_timestamp_unix(map()) :: integer() + defp get_packet_timestamp_unix(packet) do + received_at = get_packet_received_at(packet) + DateTime.to_unix(received_at, :millisecond) + end + + # Weather unit conversion helpers + defp convert_temperature(value, locale) when is_binary(value) and value != "N/A" do + case Float.parse(value) do + {num_value, _} -> + AprsmeWeb.WeatherUnits.format_temperature(num_value, locale) + + :error -> + {value, "°F"} + end + end + + defp convert_temperature(value, _locale), do: {value, "°F"} + + defp convert_wind_speed(value, locale) when is_binary(value) and value != "N/A" do + case Float.parse(value) do + {num_value, _} -> + AprsmeWeb.WeatherUnits.format_wind_speed(num_value, locale) + + :error -> + {value, "mph"} + end + end + + defp convert_wind_speed(value, _locale), do: {value, "mph"} + + defp convert_rain(value, locale) when is_binary(value) and value != "N/A" do + case Float.parse(value) do + {num_value, _} -> + AprsmeWeb.WeatherUnits.format_rain(num_value, locale) + + :error -> + {value, "in"} + end + end + + defp convert_rain(value, _locale), do: {value, "in"} +end diff --git a/lib/aprsme_web/live/map_live/display_manager.ex b/lib/aprsme_web/live/map_live/display_manager.ex new file mode 100644 index 0000000..2e8ce58 --- /dev/null +++ b/lib/aprsme_web/live/map_live/display_manager.ex @@ -0,0 +1,127 @@ +defmodule AprsmeWeb.MapLive.DisplayManager do + @moduledoc """ + Handles heat maps, markers, and zoom threshold display logic. + """ + + alias Aprsme.Packets.Clustering + alias AprsmeWeb.MapLive.BoundsManager + alias AprsmeWeb.MapLive.DataBuilder + alias Phoenix.LiveView + alias Phoenix.LiveView.Socket + + @doc """ + Handle zoom threshold crossing between heat map and marker modes. + """ + @spec handle_zoom_threshold_crossing(Socket.t(), integer()) :: Socket.t() + def handle_zoom_threshold_crossing(socket, zoom) do + if zoom <= 8 do + # Switching to heat map + socket + |> LiveView.push_event("clear_all_markers", %{}) + |> send_heat_map_for_current_bounds() + else + # Switching to markers + trigger_marker_display(socket) + end + end + + @doc """ + Check if zoom level crosses the threshold between heat map and marker modes. + """ + @spec crossing_zoom_threshold?(integer(), integer()) :: boolean() + def crossing_zoom_threshold?(old_zoom, new_zoom) do + (old_zoom <= 8 and new_zoom > 8) or (old_zoom > 8 and new_zoom <= 8) + end + + @doc """ + Send heat map data for current bounds. + """ + @spec send_heat_map_for_current_bounds(Socket.t()) :: Socket.t() + def 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 + |> BoundsManager.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 + + @doc """ + Send heat map data for specific packets. + """ + @spec send_heat_map_for_packets(Socket.t(), list()) :: Socket.t() + def 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} -> + LiveView.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 + + @doc """ + Send heat map data for filtered packets. + """ + @spec send_heat_map_data(Socket.t(), map()) :: Socket.t() + def 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 + + @doc """ + Trigger marker display mode. + """ + @spec trigger_marker_display(Socket.t()) :: Socket.t() + def trigger_marker_display(socket) do + # Clear heat map and show markers + socket = LiveView.push_event(socket, "show_markers", %{}) + + # Re-send all visible packets as markers + visible_packets_list = DataBuilder.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 + + @doc """ + Add markers to map if any exist. + """ + @spec add_markers_if_any(Socket.t(), list()) :: Socket.t() + def add_markers_if_any(socket, []), do: socket + + def add_markers_if_any(socket, markers) do + LiveView.push_event(socket, "add_markers", %{markers: markers}) + end + + @doc """ + Remove multiple markers from map. + """ + @spec remove_markers_batch(Socket.t(), list()) :: Socket.t() + def remove_markers_batch(socket, []), do: socket + + def remove_markers_batch(socket, marker_ids) do + Enum.reduce(marker_ids, socket, fn id, acc -> + LiveView.push_event(acc, "remove_marker", %{id: id}) + end) + end + + # Placeholder for historical loading function - this should be moved to HistoricalLoader + defp start_progressive_historical_loading(socket) do + # This function should be moved to HistoricalLoader module + socket + end +end diff --git a/lib/aprsme_web/live/map_live/historical_loader.ex b/lib/aprsme_web/live/map_live/historical_loader.ex new file mode 100644 index 0000000..5fe52d4 --- /dev/null +++ b/lib/aprsme_web/live/map_live/historical_loader.ex @@ -0,0 +1,271 @@ +defmodule AprsmeWeb.MapLive.HistoricalLoader do + @moduledoc """ + Handles progressive loading of historical APRS packets. + """ + + import Phoenix.Component, only: [assign: 3] + + alias Aprsme.CachedQueries + alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils + alias AprsmeWeb.MapLive.DataBuilder + alias Phoenix.LiveView + alias Phoenix.LiveView.Socket + + @max_historical_packets 5000 + + @doc """ + Start progressive historical loading based on zoom level. + """ + @spec start_progressive_historical_loading(Socket.t()) :: Socket.t() + def 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) + |> assign(:total_batches, 1) + |> assign(:historical_loading, true) + |> assign(: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) + |> assign(:total_batches, total_batches) + |> assign(:historical_loading, true) + |> assign(: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 + + @doc """ + Load a specific historical batch. + """ + @spec load_historical_batch(Socket.t(), integer(), integer() | nil) :: Socket.t() + def 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 + + @doc """ + Cancel pending batch load tasks. + """ + @spec cancel_pending_loads(Socket.t()) :: Socket.t() + def 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 + + # Private functions + + 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 = SharedPacketUtils.parse_historical_hours(socket.assigns.historical_hours || "1") + params = Map.put(params, :hours_back, historical_hours) + + 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, "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 + DataBuilder.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 + SharedPacketUtils.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 + LiveView.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 + + # 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 + + # All packet data building functions have been moved to AprsmeWeb.MapLive.DataBuilder + + # Placeholder for heat map function - this should be moved to DisplayManager + defp send_heat_map_for_current_bounds(socket) do + # This function should be moved to DisplayManager module + socket + end +end diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 68d88bc..f043ba9 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -6,121 +6,26 @@ 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, put_flash: 3] + import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_patch: 2, put_flash: 3] - alias Aprsme.CachedQueries - alias Aprsme.GeoUtils alias Aprsme.Packets.Clustering alias AprsmeWeb.Endpoint + alias AprsmeWeb.Live.Shared.BoundsUtils + alias AprsmeWeb.Live.Shared.CoordinateUtils + alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils + alias AprsmeWeb.Live.Shared.ParamUtils + alias AprsmeWeb.MapLive.BoundsManager + alias AprsmeWeb.MapLive.DataBuilder + alias AprsmeWeb.MapLive.DisplayManager + alias AprsmeWeb.MapLive.HistoricalLoader alias AprsmeWeb.MapLive.MapHelpers - alias AprsmeWeb.MapLive.PacketUtils - alias AprsmeWeb.MapLive.PopupComponent + alias AprsmeWeb.MapLive.Navigation + alias AprsmeWeb.MapLive.PacketProcessor + alias AprsmeWeb.MapLive.RfPath + alias AprsmeWeb.MapLive.UrlParams 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) when is_binary(str) do - # Sanitize input first - sanitized = sanitize_numeric_string(str) - - case Float.parse(sanitized) do - {val, ""} when val >= min and val <= max -> - if finite?(val), do: val, else: default - - {val, _remainder} when val >= min and val <= max -> - # Accept even with trailing characters, but validate the number - if finite?(val), do: val, else: default - - _ -> - default - end - end - - defp parse_float_in_range(_, default, _, _), do: default - - defp parse_int_in_range(str, default, min, max) when is_binary(str) do - # Sanitize input first - sanitized = sanitize_numeric_string(str) - - case Integer.parse(sanitized) do - {val, ""} when val >= min and val <= max -> - val - - {val, _remainder} when val >= min and val <= max -> - # Accept even with trailing characters - val - - _ -> - default - end - end - - defp parse_int_in_range(_, default, _, _), do: default - - # Sanitize numeric strings to prevent injection attacks - defp sanitize_numeric_string(str) when is_binary(str) do - # Remove any potentially dangerous characters, keeping only numbers, dots, minus, and e/E for scientific notation - str - |> String.trim() - |> String.replace(~r/[^\d\.\-eE]/, "") - # Prevent extremely long inputs - |> limit_string_length(20) - end - - defp sanitize_numeric_string(_), do: "" - - # Limit string length to prevent DoS - defp limit_string_length(str, max_length) when byte_size(str) > max_length do - :binary.part(str, 0, max_length) - end - - defp limit_string_length(str, _), do: str - - # Check if a float is finite (not infinity or NaN) - defp finite?(float) when is_float(float) do - # NaN != NaN - float != :infinity and float != :neg_infinity and float == float - end - - defp finite?(_), do: false - @impl true def mount(params, session, socket) do socket = setup_subscriptions(socket) @@ -130,7 +35,7 @@ defmodule AprsmeWeb.MapLive.Index do 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) + {map_center, map_zoom, should_skip_initial_url_update} = Navigation.determine_map_location(params, session) # Setup defaults socket = assign_defaults(socket, one_hour_ago) @@ -143,15 +48,15 @@ defmodule AprsmeWeb.MapLive.Index do tracked_callsign = Map.get(params, "call", "") {final_map_center, final_map_zoom} = - handle_callsign_tracking( + Navigation.handle_callsign_tracking( tracked_callsign, map_center, map_zoom, - has_explicit_url_params?(params) + UrlParams.has_explicit_url_params?(params) ) # Calculate initial bounds - initial_bounds = calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom) + initial_bounds = BoundsManager.calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom) # Final socket assignment {:ok, @@ -191,41 +96,6 @@ defmodule AprsmeWeb.MapLive.Index do 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, @@ -264,28 +134,6 @@ defmodule AprsmeWeb.MapLive.Index do ) 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, @@ -299,8 +147,8 @@ defmodule AprsmeWeb.MapLive.Index do east: -66.0, west: -125.0 }, - map_center: @default_center, - map_zoom: @default_zoom, + map_center: UrlParams.default_center(), + map_zoom: UrlParams.default_zoom(), historical_packets: %{}, packet_age_threshold: one_hour_ago, map_ready: false, @@ -337,12 +185,12 @@ 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 # Parse and validate coordinates - lat_float = parse_latitude(lat) - lng_float = parse_longitude(lng) + lat_float = UrlParams.parse_latitude(lat) + lng_float = UrlParams.parse_longitude(lng) # Additional validation - ensure we got valid coordinates - if valid_coordinates?(lat_float, lng_float) do - socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12) + if UrlParams.valid_coordinates?(lat_float, lng_float) do + socket = Navigation.update_and_zoom_to_location(socket, lat_float, lng_float, 12) {:noreply, socket} else # Invalid coordinates - ignore the event @@ -360,7 +208,7 @@ defmodule AprsmeWeb.MapLive.Index do socket.assigns.packet_age_threshold ) - visible_packets_list = build_packet_data_list_from_map(filtered_packets, false, socket) + visible_packets_list = DataBuilder.build_packet_data_list_from_map(filtered_packets, false, socket) socket = assign(socket, visible_packets: filtered_packets) @@ -386,12 +234,14 @@ defmodule AprsmeWeb.MapLive.Index do socket = assign(socket, map_ready: true) # If we have non-default center coordinates (e.g., from geolocation), apply them now + default_center = UrlParams.default_center() + socket = - if socket.assigns.map_center.lat == @default_center.lat and - socket.assigns.map_center.lng == @default_center.lng do + 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) + Navigation.zoom_to_current_location(socket) end # Wait for JavaScript to send the actual map bounds before loading historical packets @@ -412,20 +262,20 @@ defmodule AprsmeWeb.MapLive.Index do require Logger # Validate coordinates first - lat_float = safe_parse_coordinate(lat, 0.0, -90.0, 90.0) - lng_float = safe_parse_coordinate(lng, 0.0, -180.0, 180.0) + lat_float = ParamUtils.safe_parse_coordinate(lat, 0.0, -90.0, 90.0) + lng_float = ParamUtils.safe_parse_coordinate(lng, 0.0, -180.0, 180.0) # Validate path string - safe_path = sanitize_path_string(path) + safe_path = ParamUtils.sanitize_path_string(path) Logger.info("RF path hover start - path: #{inspect(safe_path)}") # Parse the path to find digipeater/igate stations - path_stations = parse_rf_path(safe_path) + path_stations = RfPath.parse_rf_path(safe_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) + path_station_positions = RfPath.get_path_station_positions(path_stations, socket) Logger.info("Found #{length(path_station_positions)} station positions") # Send event to draw the RF path lines @@ -597,81 +447,17 @@ defmodule AprsmeWeb.MapLive.Index do end defp parse_center_coordinates(center, socket) when is_map(center) do - default_lat = socket.assigns.map_center.lat - default_lng = socket.assigns.map_center.lng - - lat = safe_parse_coordinate(Map.get(center, "lat"), default_lat, -90.0, 90.0) - lng = safe_parse_coordinate(Map.get(center, "lng"), default_lng, -180.0, 180.0) - - {lat, lng} + CoordinateUtils.parse_center_coordinates(center, socket) end defp parse_center_coordinates(_, socket) do - # Invalid center format, return current center - {socket.assigns.map_center.lat, socket.assigns.map_center.lng} + CoordinateUtils.parse_center_coordinates(nil, socket) end - defp safe_parse_coordinate(value, default, min, max) when is_binary(value) do - case parse_float_in_range(value, default, min, max) do - ^default -> default - parsed -> clamp_coordinate(parsed, min, max) - end + defp clamp_zoom(zoom) do + ParamUtils.clamp_zoom(zoom) end - defp safe_parse_coordinate(value, default, min, max) when is_number(value) do - if finite_number?(value) do - clamp_coordinate(value, min, max) - else - default - end - end - - defp safe_parse_coordinate(_, default, _, _), do: default - - defp clamp_coordinate(value, min, max) when is_number(value) do - value |> max(min) |> min(max) - end - - defp clamp_coordinate(_, _, _), do: 0.0 - - defp clamp_zoom(zoom) when is_binary(zoom) do - parse_int_in_range(zoom, @default_zoom, 1, 20) - end - - defp clamp_zoom(zoom) when is_integer(zoom) do - max(1, min(20, zoom)) - end - - defp clamp_zoom(zoom) when is_float(zoom) do - max(1, min(20, trunc(zoom))) - end - - defp clamp_zoom(_), do: @default_zoom - - # Helper to check if a number is finite - defp finite_number?(num) when is_number(num) do - # Convert integer to float for check - finite?(num / 1.0) - end - - defp finite_number?(_), do: false - - # Validate that coordinates are within reasonable ranges - defp valid_coordinates?(lat, lng) when is_number(lat) and is_number(lng) do - lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 - end - - defp valid_coordinates?(_, _), do: false - - # 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) @@ -686,19 +472,11 @@ defmodule AprsmeWeb.MapLive.Index do 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) + DisplayManager.crossing_zoom_threshold?(old_zoom, new_zoom) 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 + DisplayManager.handle_zoom_threshold_crossing(socket, zoom) end defp handle_url_update(socket, lat, lng, zoom) do @@ -745,14 +523,8 @@ defmodule AprsmeWeb.MapLive.Index do 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 + Navigation.handle_callsign_search(callsign, socket) end @impl true @@ -765,7 +537,7 @@ defmodule AprsmeWeb.MapLive.Index do {:noreply, socket} else # Parse new map state from URL parameters - {map_center, map_zoom} = parse_map_params(params) + {map_center, map_zoom} = UrlParams.parse_map_params(params) # Update socket state socket = assign(socket, map_center: map_center, map_zoom: map_zoom) @@ -787,32 +559,10 @@ defmodule AprsmeWeb.MapLive.Index do 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 + defp parse_trail_duration(duration), do: SharedPacketUtils.parse_trail_duration(duration) # 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 + defp parse_historical_hours(hours), do: SharedPacketUtils.parse_historical_hours(hours) @impl true def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket) @@ -838,14 +588,14 @@ defmodule AprsmeWeb.MapLive.Index do 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) + socket = HistoricalLoader.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) + socket = HistoricalLoader.load_historical_batch(socket, batch_offset, generation) {:noreply, socket} else # Stale request, ignore it @@ -870,7 +620,7 @@ defmodule AprsmeWeb.MapLive.Index do should_process = !socket.assigns[:initial_bounds_loaded] or socket.assigns[:needs_initial_historical_load] or - not compare_bounds(map_bounds, socket.assigns.map_bounds) + not BoundsUtils.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]}" @@ -892,7 +642,7 @@ defmodule AprsmeWeb.MapLive.Index 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 = HistoricalLoader.start_progressive_historical_loading(socket) socket = assign(socket, historical_loaded: true) {:noreply, socket} else @@ -924,125 +674,7 @@ defmodule AprsmeWeb.MapLive.Index do 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 + PacketProcessor.process_packet_for_display(packet, socket) end # Handle replaying the next historical packet @@ -1264,7 +896,7 @@ defmodule AprsmeWeb.MapLive.Index do <%= if @historical_loading do %> -