diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md new file mode 100644 index 0000000..81053c9 --- /dev/null +++ b/.kiro/steering/product.md @@ -0,0 +1,18 @@ +# Product Overview + +Aprsme is an APRS (Automatic Packet Reporting System) web application that provides real-time tracking and visualization of amateur radio packets. The application connects to APRS-IS servers to receive and process amateur radio position reports, weather data, and other telemetry. + +## Core Features + +- **Real-time Map Visualization**: Interactive map showing APRS stations and their positions +- **Packet Processing**: Ingests and processes APRS packets from APRS-IS network +- **Station Information**: Detailed views of individual callsigns and their activity +- **Weather Data**: Weather station reporting and visualization +- **Bad Packet Analysis**: Monitoring and analysis of malformed packets +- **Multi-language Support**: Internationalization with English, Spanish, German, and French +- **User Authentication**: User accounts and session management +- **API Access**: RESTful API for programmatic access to data + +## Technical Architecture + +The application uses a GenStage-based packet processing pipeline to handle high-volume APRS data streams, with PostgreSQL for persistence and Phoenix LiveView for real-time web interfaces. Background job processing is handled by Oban for maintenance tasks like packet cleanup. diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md new file mode 100644 index 0000000..48d98c5 --- /dev/null +++ b/.kiro/steering/structure.md @@ -0,0 +1,115 @@ +# Project Structure + +## Root Directory Layout + +``` +├── lib/ # Application source code +├── test/ # Test files +├── config/ # Configuration files +├── priv/ # Private application files (migrations, static assets) +├── assets/ # Frontend assets (CSS, JS, images) +├── deps/ # Dependencies (managed by Mix) +├── _build/ # Compiled artifacts +├── k8s/ # Kubernetes deployment manifests +├── rel/ # Release configuration +└── vendor/ # Vendored dependencies (custom APRS library) +``` + +## Application Code Structure (`lib/`) + +### Core Application (`lib/aprsme/`) + +- **Business Logic**: Core domain modules for APRS packet processing +- **Data Layer**: Ecto schemas, repos, and database interactions +- **Background Jobs**: Oban workers for maintenance tasks +- **External Integrations**: APRS-IS connection and packet processing pipeline + +#### Key Subdirectories: + +- `accounts/` - User authentication and management +- `is/` - APRS-IS server connection and supervision +- `packets/` - Packet processing, clustering, and queries +- `workers/` - Background job workers + +### Web Layer (`lib/aprsme_web/`) + +- **Controllers**: HTTP request handlers and API endpoints +- **LiveViews**: Real-time interactive pages +- **Components**: Reusable UI components +- **Plugs**: Custom middleware for authentication, rate limiting, etc. + +#### Key Subdirectories: + +- `controllers/` - Traditional Phoenix controllers and API endpoints +- `live/` - Phoenix LiveView modules organized by feature +- `components/` - Shared UI components and layouts +- `plugs/` - Custom Plug modules + +## Configuration Structure (`config/`) + +- `config.exs` - Base configuration +- `dev.exs` - Development environment +- `prod.exs` - Production environment +- `runtime.exs` - Runtime configuration (environment variables) +- `test.exs` - Test environment + +## Test Structure (`test/`) + +- Mirrors `lib/` structure with `_test.exs` suffix +- `test/support/` - Test helpers and fixtures +- `test/fixtures/` - Test data fixtures +- `test/integration/` - Integration tests + +## Asset Structure (`assets/`) + +- `css/` - Tailwind CSS files +- `js/` - JavaScript/TypeScript files + - `features/` - Feature-specific JS modules + - `hooks/` - Phoenix LiveView hooks + - `types/` - TypeScript type definitions + +## Database Structure (`priv/repo/`) + +- `migrations/` - Ecto database migrations +- `seeds.exs` - Database seeding script + +## Naming Conventions + +### Modules + +- **Contexts**: `Aprsme.ContextName` (e.g., `Aprsme.Accounts`, `Aprsme.Packets`) +- **Schemas**: `Aprsme.Context.SchemaName` (e.g., `Aprsme.Accounts.User`) +- **Web Modules**: `AprsmeWeb.ModuleName` (e.g., `AprsmeWeb.UserController`) +- **LiveViews**: `AprsmeWeb.FeatureLive.Action` (e.g., `AprsmeWeb.MapLive.Index`) + +### Files + +- **Contexts**: `snake_case.ex` (e.g., `packet_consumer.ex`) +- **Tests**: `module_name_test.exs` +- **Templates**: `action_name.html.heex` for LiveView templates + +## Architecture Patterns + +### Phoenix Contexts + +- Business logic organized into bounded contexts +- Each context has a main module that serves as the public API +- Internal modules handle specific responsibilities + +### GenStage Pipeline + +- `PacketProducer` → `PacketConsumer` pipeline for APRS data processing +- Supervised by `PacketPipelineSupervisor` +- Configurable batch processing parameters + +### LiveView Organization + +- Feature-based organization (e.g., `map_live/`, `packets_live/`) +- Shared components in `components/` +- Shared utilities in `shared/` + +### Error Handling + +- `ErrorTracker` for application error monitoring +- Custom error handlers and circuit breakers for external services +- Structured logging with sanitization for sensitive data diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md new file mode 100644 index 0000000..a8bb766 --- /dev/null +++ b/.kiro/steering/tech.md @@ -0,0 +1,100 @@ +# Technology Stack + +## Core Technologies + +- **Elixir**: ~> 1.17 - Primary programming language +- **Phoenix Framework**: ~> 1.8 - Web framework +- **Phoenix LiveView**: ~> 1.0.17 - Real-time web interfaces +- **PostgreSQL**: Database with PostGIS extension for geospatial data +- **Ecto**: Database wrapper and query generator + +## Key Dependencies + +### Web & UI + +- **Bandit**: HTTP server (replaces Cowboy) +- **Phoenix LiveDashboard**: Development and monitoring dashboard +- **Tailwind CSS**: Utility-first CSS framework +- **Heroicons**: Icon library +- **ESBuild**: JavaScript bundler + +### Data Processing + +- **GenStage**: Stream processing for APRS packet pipeline +- **Oban**: Background job processing +- **Cachex**: In-memory caching +- **Geo/PostGIS**: Geospatial data handling + +### External Integrations + +- **HTTPoison/Req**: HTTP clients +- **Jason**: JSON encoding/decoding +- **Swoosh**: Email delivery + +### Development Tools + +- **Credo**: Static code analysis +- **Dialyxir**: Static type analysis +- **Styler**: Code formatting +- **ExVCR**: HTTP interaction recording for tests +- **Sobelow**: Security-focused static analysis + +## Common Commands + +### Development Setup + +```bash +mix deps.get # Install dependencies +mix ecto.setup # Create and migrate database +mix phx.server # Start development server +iex -S mix phx.server # Start with interactive shell +``` + +### Database Operations + +```bash +mix ecto.create # Create database +mix ecto.migrate # Run migrations +mix ecto.reset # Drop, create, and migrate database +mix ecto.gen.migration # Generate new migration +``` + +### Code Quality + +```bash +mix credo # Run static analysis +mix dialyzer # Run type analysis +mix format # Format code +mix test # Run test suite +mix test.watch # Run tests in watch mode +``` + +### Asset Management + +```bash +mix assets.deploy # Build and optimize assets for production +mix tailwind default # Compile Tailwind CSS +mix esbuild default # Compile JavaScript +``` + +### Production + +```bash +mix release # Build production release +mix phx.digest # Generate asset digests +``` + +## Configuration + +- **Development**: `config/dev.exs` +- **Production**: `config/prod.exs` +- **Runtime**: `config/runtime.exs` +- **Test**: `config/test.exs` +- **Base**: `config/config.exs` + +## Code Style + +- Uses **Styler** plugin for consistent formatting +- **Credo** enforces code quality with 120 character line limit +- **Dialyzer** for static type analysis +- Import dependencies: `:ecto`, `:ecto_sql`, `:phoenix`, `:stream_data` diff --git a/config/config.exs b/config/config.exs index f32f488..cf41a10 100644 --- a/config/config.exs +++ b/config/config.exs @@ -55,6 +55,8 @@ config :aprsme, aprs_is_password: System.get_env("APRS_PASSCODE"), auto_migrate: true, env: config_env(), + # Packet retention period in days (default: 365 days = 1 year) + packet_retention_days: String.to_integer(System.get_env("PACKET_RETENTION_DAYS", "365")), # GenStage packet processing configuration packet_pipeline: [ max_buffer_size: 1000, diff --git a/lib/aprsme/encoding_utils.ex b/lib/aprsme/encoding_utils.ex index 54f30ff..6a5af5f 100644 --- a/lib/aprsme/encoding_utils.ex +++ b/lib/aprsme/encoding_utils.ex @@ -84,7 +84,7 @@ defmodule Aprsme.EncodingUtils do def sanitize_string(other), do: other @doc """ - Converts various types to float for consistent display. + Converts various types to float with validation for safety. ## Examples @@ -100,19 +100,50 @@ defmodule Aprsme.EncodingUtils do nil """ @spec to_float(any()) :: float() | nil - def to_float(value) when is_float(value), do: value - def to_float(value) when is_integer(value), do: value * 1.0 - def to_float(%Decimal{} = value), do: Decimal.to_float(value) + def to_float(value) when is_float(value) do + 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 + end + end + + def to_float(%Decimal{} = value) do + float = Decimal.to_float(value) + if finite_float?(float), do: float + end def to_float(value) when is_binary(value) do - case Float.parse(value) do - {float, _} -> float - :error -> nil + # Sanitize input first to prevent injection attacks + sanitized = + value + |> sanitize_string() + |> to_string() + # Reasonable max length for a number + |> String.slice(0, 30) + + case Float.parse(sanitized) do + {float, _} when is_float(float) -> + if finite_float?(float), do: float + + :error -> + nil end end def to_float(_), do: nil + # Helper to check if a float is finite (not infinity or NaN) + defp finite_float?(float) when is_float(float) do + # NaN != NaN + float != :infinity and float != :neg_infinity and float == float + end + + defp finite_float?(_), do: false + @doc """ Converts various types to Decimal for database storage. 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 0b4e911..f043ba9 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -6,68 +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] + 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 - - # Parse map state from URL parameters - @spec parse_map_params(map()) :: {map(), integer()} - defp parse_map_params(params) do - # Parse latitude (lat parameter) - lat = - case Map.get(params, "lat") do - nil -> - @default_center.lat - - lat_str -> - case Float.parse(lat_str) do - {lat_val, _} when lat_val >= -90 and lat_val <= 90 -> lat_val - _ -> @default_center.lat - end - end - - # Parse longitude (lng parameter) - lng = - case Map.get(params, "lng") do - nil -> - @default_center.lng - - lng_str -> - case Float.parse(lng_str) do - {lng_val, _} when lng_val >= -180 and lng_val <= 180 -> lng_val - _ -> @default_center.lng - end - end - - # Parse zoom level (z parameter) - zoom = - case Map.get(params, "z") do - nil -> - @default_zoom - - zoom_str -> - case Integer.parse(zoom_str) do - {zoom_val, _} when zoom_val >= 1 and zoom_val <= 20 -> zoom_val - _ -> @default_zoom - end - end - - map_center = %{lat: lat, lng: lng} - {map_center, zoom} - end - @impl true def mount(params, session, socket) do socket = setup_subscriptions(socket) @@ -77,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) @@ -90,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, @@ -114,58 +72,29 @@ 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 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 do_setup_additional_subscriptions(socket, false), do: socket defp finalize_mount_assigns(socket, %{ initial_bounds: initial_bounds, @@ -197,41 +126,14 @@ 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 - # Calculate approximate bounds based on center point and zoom level - # This provides initial bounds for database queries before client sends actual bounds - @spec calculate_bounds_from_center_and_zoom(map(), integer()) :: map() - defp calculate_bounds_from_center_and_zoom(center, zoom) do - # Approximate degrees per pixel at different zoom levels - # These are rough estimates for initial bounds calculation - degrees_per_pixel = - case zoom do - z when z >= 15 -> 0.000005 - z when z >= 12 -> 0.00005 - z when z >= 10 -> 0.0002 - z when z >= 8 -> 0.001 - z when z >= 6 -> 0.005 - z when z >= 4 -> 0.02 - _ -> 0.1 - end - - # Assume viewport is roughly 800x600 pixels - # Half of 600px height - lat_offset = degrees_per_pixel * 300 - # Half of 800px width - lng_offset = degrees_per_pixel * 400 - - %{ - north: center.lat + lat_offset, - south: center.lat - lat_offset, - east: center.lng + lng_offset, - west: center.lng - lng_offset - } - end - @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t() defp assign_defaults(socket, one_hour_ago) do assign(socket, @@ -245,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, @@ -254,6 +156,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", @@ -278,24 +184,18 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do # Update map center and zoom when location is received - # Ensure coordinates are floats - lat_float = - cond do - is_binary(lat) -> String.to_float(lat) - is_integer(lat) -> lat / 1.0 - true -> lat - end + # Parse and validate coordinates + lat_float = UrlParams.parse_latitude(lat) + lng_float = UrlParams.parse_longitude(lng) - lng_float = - cond do - is_binary(lng) -> String.to_float(lng) - is_integer(lng) -> lng / 1.0 - true -> lng - end - - socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12) - - {:noreply, socket} + # Additional validation - ensure we got valid coordinates + 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 + {:noreply, socket} + end end @impl true @@ -308,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) @@ -334,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 @@ -359,22 +261,29 @@ defmodule AprsmeWeb.MapLive.Index do def handle_event("marker_hover_start", %{"id" => _id, "path" => path, "lat" => lat, "lng" => lng}, socket) do require Logger - Logger.info("RF path hover start - path: #{inspect(path)}") + # Validate coordinates first + 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 = 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(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 socket = if length(path_station_positions) > 0 do push_event(socket, "draw_rf_path", %{ - station_lat: lat, - station_lng: lng, + station_lat: lat_float, + station_lng: lng_float, path_stations: path_station_positions }) else @@ -427,8 +336,10 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket) do - # Convert duration string to hours and calculate new threshold - hours = String.to_integer(duration) + # Validate and convert duration string to hours + hours = parse_trail_duration(duration) + + # Calculate new threshold safely new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold) @@ -441,7 +352,9 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket) do - socket = assign(socket, historical_hours: hours) + # Validate hours value + validated_hours = parse_historical_hours(hours) + socket = assign(socket, historical_hours: to_string(validated_hours)) # Trigger a reload of historical packets with the new time range if socket.assigns.map_ready do @@ -453,14 +366,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,28 +429,33 @@ 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}") - # Validate and clamp values - lat = max(-90.0, min(90.0, lat)) - lng = max(-180.0, min(180.0, lng)) + # 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]}) - {lat, lng} + {:noreply, socket} + end + + defp parse_center_coordinates(center, socket) when is_map(center) do + CoordinateUtils.parse_center_coordinates(center, socket) + end + + defp parse_center_coordinates(_, socket) do + CoordinateUtils.parse_center_coordinates(nil, socket) end defp clamp_zoom(zoom) do - max(1, min(20, zoom)) + ParamUtils.clamp_zoom(zoom) end defp update_map_state(socket, map_center, zoom) do @@ -558,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 @@ -617,21 +523,8 @@ 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 - - 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 + Navigation.handle_callsign_search(callsign, socket) end @impl true @@ -644,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) @@ -665,6 +558,12 @@ defmodule AprsmeWeb.MapLive.Index do end end + # Parse trail duration with validation and bounds checking + defp parse_trail_duration(duration), do: SharedPacketUtils.parse_trail_duration(duration) + + # Parse historical hours with validation + 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) @@ -676,14 +575,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 = 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 = HistoricalLoader.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 @@ -693,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]}" @@ -715,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 @@ -747,110 +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 - all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet) - socket = assign(socket, all_packets: all_packets) - - # Handle packet visibility logic - handle_packet_visibility(packet, lat, lon, callsign_key, socket) - end - - defp get_callsign_key(packet) do - if Map.has_key?(packet, "id"), - do: to_string(packet["id"]), - else: System.unique_integer([:positive]) - end - - defp handle_packet_visibility(packet, lat, lon, callsign_key, socket) do - cond do - should_remove_marker?(lat, lon, callsign_key, socket) -> - remove_marker_from_map(callsign_key, socket) - - should_add_marker?(lat, lon, callsign_key, socket) -> - handle_valid_postgres_packet(packet, lat, lon, socket) - - 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) - {:noreply, socket} - end - - true -> - {:noreply, socket} - end - end - - defp should_remove_marker?(lat, lon, callsign_key, socket) do - !is_nil(lat) and !is_nil(lon) and - Map.has_key?(socket.assigns.visible_packets, callsign_key) and - not MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) - end - - defp should_add_marker?(lat, lon, callsign_key, socket) do - !is_nil(lat) and !is_nil(lon) and - not Map.has_key?(socket.assigns.visible_packets, callsign_key) and - MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) - end - - defp 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) - {:noreply, assign(socket, visible_packets: new_visible_packets)} - end - - defp handle_valid_postgres_packet(packet, _lat, _lon, socket) do - # Add the packet to visible_packets and push a marker immediately - callsign_key = - if Map.has_key?(packet, "id"), - do: to_string(packet["id"]), - else: System.unique_integer([:positive]) - - new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet) - socket = assign(socket, visible_packets: new_visible_packets) - - # Check zoom level to decide how to display the packet - socket = - 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 - - {:noreply, socket} + PacketProcessor.process_packet_for_display(packet, socket) end # Handle replaying the next historical packet @@ -1006,6 +830,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 +894,20 @@ defmodule AprsmeWeb.MapLive.Index do <% end %> + + <%= if @historical_loading do %> +
+
+ + {gettext("Loading historical data...")} + <%= if @loading_batch && @total_batches do %> + ({@loading_batch}/{@total_batches}) + <% end %> + +
+ <% end %> + <%= if @slideover_open do %>
Enum.filter(fn {_key, packet} -> - not packet_within_time_threshold?(packet, threshold) + not SharedPacketUtils.packet_within_time_threshold?(packet, threshold) end) |> Enum.map(fn {key, _} -> key end) # Only update the client if there are expired markers - socket = remove_markers_batch(socket, expired_keys) + socket = DisplayManager.remove_markers_batch(socket, expired_keys) # Use Map.drop/2 for better performance updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys) @@ -1373,7 +1213,7 @@ defmodule AprsmeWeb.MapLive.Index do socket = push_event(socket, "clear_historical_packets", %{}) # Start progressive loading using LiveView's efficient batching - socket = start_progressive_historical_loading(socket) + socket = HistoricalLoader.start_progressive_historical_loading(socket) {:noreply, socket} else @@ -1382,471 +1222,41 @@ defmodule AprsmeWeb.MapLive.Index do end end - # Check if a packet is within the time threshold (not too old) - @spec packet_within_time_threshold?(struct(), any()) :: boolean() - defp packet_within_time_threshold?(packet, threshold) do - case packet do - %{received_at: received_at} when not is_nil(received_at) -> - threshold_dt = convert_threshold_to_datetime(threshold) - DateTime.compare(received_at, threshold_dt) in [:gt, :eq] - - _ -> - # If no timestamp, treat as current - true - end - end - - defp convert_threshold_to_datetime(threshold) do - cond do - is_integer(threshold) -> - # Assume seconds since epoch - DateTime.from_unix!(threshold) - - is_binary(threshold) -> - case DateTime.from_iso8601(threshold) do - {:ok, dt, _} -> dt - _ -> DateTime.utc_now() - end - - match?(%DateTime{}, threshold) -> - threshold - - true -> - DateTime.utc_now() - end - end - # Helper functions # Fetch historical packets from the database # Select the best packet to display for a callsign - prioritize position over weather - defp select_best_packet_for_display(packets) do - # Separate position and weather packets using the same logic as PacketUtils.weather_packet? - {position_packets, weather_packets} = - Enum.split_with(packets, fn packet -> - # A packet is a position packet if it's NOT a weather packet - not PacketUtils.weather_packet?(packet) - end) - - # Prefer the most recent position packet, fall back to most recent weather packet - case position_packets do - [] -> - # No position packets, use most recent weather packet - hd(weather_packets) - - [single_position] -> - # Only one position packet, use it - single_position - - position_list -> - # Multiple position packets, use most recent one - Enum.max_by(position_list, & &1.received_at, DateTime) - end - end - - defp build_packet_data_list(historical_packets) do - # Include weather data in initial grouping to avoid separate query - grouped_packets = - Enum.group_by(historical_packets, fn packet -> - packet.sender || "unknown" - end) - - # Build weather callsign set from packets themselves (no DB query needed) - weather_callsigns = build_weather_callsign_set(historical_packets) - - # For each callsign group, find the most recent packet and mark it appropriately - grouped_packets - |> Enum.flat_map(fn {callsign, packets} -> - # Sort by received_at to find most recent - sorted_packets = Enum.sort_by(packets, & &1.received_at, {:desc, DateTime}) - - case sorted_packets do - [] -> - [] - - packets_list -> - # Find the best packet to display as "current" - prioritize position over weather - selected_packet = select_best_packet_for_display(packets_list) - historical = Enum.reject(packets_list, &(&1.id == selected_packet.id)) - - # Always include the selected packet - has_weather = MapSet.member?(weather_callsigns, String.upcase(callsign)) - most_recent_data = build_minimal_packet_data(selected_packet, true, has_weather) - - # Get coordinates of selected packet for distance filtering - {most_recent_lat, most_recent_lon, _} = MapHelpers.get_coordinates(selected_packet) - - # Filter historical packets that are too close to most recent position - filtered_historical = - if most_recent_lat && most_recent_lon do - Enum.filter(historical, fn packet -> - {lat, lon, _} = MapHelpers.get_coordinates(packet) - - if lat && lon do - distance_meters = calculate_distance_meters(most_recent_lat, most_recent_lon, lat, lon) - # Only show if 10+ meters away - distance_meters >= 10.0 - else - # Skip packets without coordinates - false - end - end) - else - # If most recent has no coordinates, include all historical - historical - end - - # Build data for remaining historical packets - historical_data = - filtered_historical - |> Enum.map(fn packet -> build_minimal_packet_data(packet, false, has_weather) end) - |> Enum.filter(& &1) - - # Combine most recent and filtered historical - Enum.filter([most_recent_data | historical_data], & &1) - end - end) - |> Enum.filter(& &1) - end - - defp build_minimal_packet_data(packet, is_most_recent, has_weather) do - # Build minimal packet data without calling expensive PacketUtils.build_packet_data - {lat, lon, _} = MapHelpers.get_coordinates(packet) - - if lat && lon do - # Use PacketUtils to get symbol information properly (includes data_extended fallback) - symbol_table_id = PacketUtils.get_packet_field(packet, :symbol_table_id, "/") - symbol_code = PacketUtils.get_packet_field(packet, :symbol_code, ">") - - # Generate symbol HTML using the SymbolRenderer - symbol_html = - AprsmeWeb.SymbolRenderer.render_marker_symbol( - symbol_table_id, - symbol_code, - packet.sender || "", - 32 - ) - - %{ - "id" => if(is_most_recent, do: "current_#{packet.id}", else: "hist_#{packet.id}"), - "lat" => lat, - "lng" => lon, - "callsign" => packet.sender || "", - "symbol_table_id" => symbol_table_id, - "symbol_code" => symbol_code, - "symbol_html" => symbol_html, - "comment" => packet.comment || "", - "timestamp" => DateTime.to_unix(packet.received_at || DateTime.utc_now(), :millisecond), - "historical" => !is_most_recent, - "is_most_recent_for_callsign" => is_most_recent, - "path" => packet.path || "", - "popup" => build_simple_popup(packet, has_weather) - } - end - end - - defp build_simple_popup(packet, has_weather) do - # Build popup HTML directly without database queries - callsign = packet.sender || "Unknown" - timestamp_dt = packet.received_at || DateTime.utc_now() - cache_buster = System.system_time(:millisecond) - - # Check if this packet itself is a weather packet - is_weather = PacketUtils.weather_packet?(packet) - - if is_weather do - # Build weather popup - %{ - callsign: callsign, - comment: nil, - timestamp_dt: timestamp_dt, - cache_buster: cache_buster, - weather: true, - weather_link: true, - temperature: PacketUtils.get_weather_field(packet, :temperature), - temp_unit: "°F", - humidity: PacketUtils.get_weather_field(packet, :humidity), - wind_direction: PacketUtils.get_weather_field(packet, :wind_direction), - wind_speed: PacketUtils.get_weather_field(packet, :wind_speed), - wind_unit: "mph", - wind_gust: PacketUtils.get_weather_field(packet, :wind_gust), - gust_unit: "mph", - pressure: PacketUtils.get_weather_field(packet, :pressure), - rain_1h: PacketUtils.get_weather_field(packet, :rain_1h), - rain_24h: PacketUtils.get_weather_field(packet, :rain_24h), - rain_since_midnight: PacketUtils.get_weather_field(packet, :rain_since_midnight), - rain_1h_unit: "in", - rain_24h_unit: "in", - rain_since_midnight_unit: "in" - } - |> PopupComponent.popup() - |> Safe.to_iodata() - |> IO.iodata_to_binary() - else - # Build standard popup - %{ - callsign: callsign, - comment: packet.comment || "", - timestamp_dt: timestamp_dt, - cache_buster: cache_buster, - weather: false, - # Use pre-fetched weather info - weather_link: has_weather - } - |> PopupComponent.popup() - |> Safe.to_iodata() - |> IO.iodata_to_binary() - end - end - - # Build weather callsign set from packets themselves (avoids DB query) - defp build_weather_callsign_set(packets) do - packets - |> Enum.filter(&PacketUtils.weather_packet?/1) - |> MapSet.new(fn packet -> String.upcase(packet.sender || "") end) - end - - # Calculate distance between two lat/lon points in meters using Haversine formula - defp calculate_distance_meters(lat1, lon1, lat2, lon2) do - # Convert latitude and longitude from degrees to radians - lat1_rad = lat1 * :math.pi() / 180 - lon1_rad = lon1 * :math.pi() / 180 - lat2_rad = lat2 * :math.pi() / 180 - lon2_rad = lon2 * :math.pi() / 180 - - # Haversine formula - dlat = lat2_rad - lat1_rad - dlon = lon2_rad - lon1_rad - - a = - :math.sin(dlat / 2) * :math.sin(dlat / 2) + - :math.cos(lat1_rad) * :math.cos(lat2_rad) * - :math.sin(dlon / 2) * :math.sin(dlon / 2) - - c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) - - # Earth's radius in meters - earth_radius_meters = 6_371_000 - - # Distance in meters - earth_radius_meters * c - end - - # Progressive loading functions using LiveView's efficient update mechanisms - @spec start_progressive_historical_loading(Socket.t()) :: Socket.t() - defp start_progressive_historical_loading(socket) do - require Logger - - Logger.debug( - "start_progressive_historical_loading called with zoom: #{socket.assigns.map_zoom}, bounds: #{inspect(socket.assigns.map_bounds)}" - ) - - # Don't clear historical packets here - let the caller decide if clearing is needed - # This prevents race conditions where we clear packets that were just loaded - - # For high zoom levels, load everything in one batch for maximum speed - zoom = socket.assigns.map_zoom || 5 - - if zoom >= 10 do - # High zoom - load everything at once for maximum speed - Logger.debug("High zoom (#{zoom}), loading in single batch") - - socket - |> assign(loading_batch: 0, total_batches: 1) - |> load_historical_batch(0) - else - # Low zoom - use progressive loading to prevent overwhelming - total_batches = calculate_batch_count_for_zoom(zoom) - - # Start with first batch - socket = - socket - |> assign(loading_batch: 0, total_batches: total_batches) - |> load_historical_batch(0) - - # Load all remaining batches immediately - no delays - Enum.each(1..(total_batches - 1), fn batch_index -> - send(self(), {:load_historical_batch, batch_index}) - end) - - socket - end - end - - # 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 - - @spec calculate_batch_count_for_zoom(integer()) :: integer() - defp calculate_batch_count_for_zoom(zoom) do - {_, batch_count} = get_loading_params_for_zoom(zoom) - batch_count - end - - @spec load_historical_batch(Socket.t(), integer()) :: Socket.t() - defp load_historical_batch(socket, batch_offset) do - if socket.assigns.map_bounds do - bounds = [ - socket.assigns.map_bounds.west, - socket.assigns.map_bounds.south, - socket.assigns.map_bounds.east, - socket.assigns.map_bounds.north - ] - - # Calculate zoom-based batch size - higher zoom = smaller batches for faster response - zoom = socket.assigns.map_zoom || 5 - batch_size = calculate_batch_size_for_zoom(zoom) - offset = batch_offset * batch_size - - packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets) - - historical_packets = - 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 - historical_hours = String.to_integer(socket.assigns.historical_hours || "1") - params = Map.put(params, :hours_back, historical_hours) - - Aprsme.CachedQueries.get_recent_packets_cached(params) - else - # Fallback for testing - packets_module.get_recent_packets_optimized(%{ - bounds: bounds, - limit: batch_size, - offset: offset - }) - end - rescue - e -> - require Logger - - Logger.error("Error loading historical packets: #{inspect(e)}") - Logger.error("Stack trace: #{Exception.format_stacktrace()}") - [] - end - - if Enum.any?(historical_packets) do - # Process this batch and send to frontend - packet_data_list = - try do - build_packet_data_list(historical_packets) - rescue - e -> - require Logger - - Logger.error("Error building packet data list: #{inspect(e)}") - [] - end - - if Enum.any?(packet_data_list) do - # Check zoom level to decide between heat map and markers - total_batches = socket.assigns.total_batches || 4 - is_final_batch = batch_offset >= total_batches - 1 - - socket = - if socket.assigns.map_zoom <= 8 do - # For heat maps, store historical packets and update heat map when all batches are loaded - # Add packets to historical_packets assign - new_historical = - Enum.reduce(historical_packets, socket.assigns.historical_packets, fn packet, acc -> - key = if Map.has_key?(packet, :id), do: to_string(packet.id), else: to_string(packet["id"]) - Map.put(acc, key, packet) - end) - - socket = assign(socket, historical_packets: new_historical) - - # If this is the final batch, update the heat map - if is_final_batch do - send_heat_map_for_current_bounds(socket) - else - socket - end - else - # Use LiveView's efficient push_event for incremental updates - push_event(socket, "add_historical_packets_batch", %{ - packets: packet_data_list, - batch: batch_offset, - is_final: is_final_batch - }) - end - - # Update progress for user feedback - socket = assign(socket, loading_batch: batch_offset + 1) - - socket - else - socket - end - else - socket - end - else - socket - end - end @spec within_bounds?(map() | struct(), map()) :: boolean() defp within_bounds?(packet, bounds) do {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) # Basic validation - if is_nil(lat) or is_nil(lon) do - false - else - # Check latitude bounds (straightforward) - lat_in_bounds = lat >= bounds.south && lat <= bounds.north + check_coordinate_bounds(lat, lon, bounds) + end - # Check longitude bounds (handle potential wrapping) - lng_in_bounds = - if bounds.west <= bounds.east do - # Normal case: bounds don't cross antimeridian - lon >= bounds.west && lon <= bounds.east - else - # Bounds cross antimeridian (e.g., west=170, east=-170) - lon >= bounds.west || lon <= bounds.east - end + defp check_coordinate_bounds(nil, _, _), do: false + defp check_coordinate_bounds(_, nil, _), do: false - lat_in_bounds && lng_in_bounds - end + defp check_coordinate_bounds(lat, lon, bounds) do + # Check latitude bounds (straightforward) + lat_in_bounds = lat >= bounds.south && lat <= bounds.north + + # Check longitude bounds (handle potential wrapping) + lng_in_bounds = check_longitude_bounds(lon, bounds.west, bounds.east) + + lat_in_bounds && lng_in_bounds + end + + defp check_longitude_bounds(lon, west, east) when west <= east do + # Normal case: bounds don't cross antimeridian + lon >= west && lon <= east + end + + defp check_longitude_bounds(lon, west, east) do + # Bounds cross antimeridian (e.g., west=170, east=-170) + lon >= west || lon <= east end # Helper functions to reduce duplicate filtering logic @@ -1875,7 +1285,7 @@ defmodule AprsmeWeb.MapLive.Index do packets |> Enum.filter(fn {_callsign, packet} -> within_bounds?(packet, bounds) && - packet_within_time_threshold?(packet, time_threshold) + SharedPacketUtils.packet_within_time_threshold?(packet, time_threshold) end) |> Map.new() end @@ -1906,46 +1316,34 @@ defmodule AprsmeWeb.MapLive.Index do Process.cancel_timer(socket.assigns.bounds_update_timer) end + # Clean up any pending batch tasks + if socket.assigns[:pending_batch_tasks] do + Enum.each(socket.assigns.pending_batch_tasks, &Process.cancel_timer/1) + end + :ok end - # Add a helper for robust bounds comparison - - defp compare_bounds(b1, b2) do - # Handle nil bounds - cond do - is_nil(b1) and is_nil(b2) -> true - is_nil(b1) or is_nil(b2) -> false - true -> compare_bounds_maps(b1, b2) - end - end - - defp compare_bounds_maps(b1, b2) do - round4 = fn x -> - case x do - n when is_float(n) -> Float.round(n, 4) - n when is_integer(n) -> n * 1.0 - _ -> x - end - end - - Enum.all?([:north, :south, :east, :west], fn key -> - round4.(Map.get(b1, key)) == round4.(Map.get(b2, key)) - end) - end + # Bounds comparison now handled by shared utilities # --- Private bounds update helpers --- @spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()} defp handle_bounds_update(bounds, socket) do - # Update the map bounds from the client, converting to atom keys - map_bounds = MapHelpers.normalize_bounds(bounds) - - # Validate bounds to prevent invalid coordinates - if valid_bounds?(map_bounds) do - handle_valid_bounds_update(map_bounds, socket) + # Skip if we're currently loading historical data + if socket.assigns.historical_loading do + # Defer the bounds update + {:noreply, assign(socket, pending_bounds: bounds)} else - # Invalid bounds, skip update - {:noreply, socket} + # Update the map bounds from the client, converting to atom keys + map_bounds = MapHelpers.normalize_bounds(bounds) + + # Validate bounds to prevent invalid coordinates + if valid_bounds?(map_bounds) do + handle_valid_bounds_update(map_bounds, socket) + else + # Invalid bounds, skip update + {:noreply, socket} + end end end @@ -1963,7 +1361,7 @@ defmodule AprsmeWeb.MapLive.Index do socket = process_bounds_update(map_bounds, socket) {:noreply, socket} - compare_bounds(map_bounds, socket.assigns.map_bounds) -> + BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds) -> {:noreply, socket} true -> @@ -1977,7 +1375,7 @@ defmodule AprsmeWeb.MapLive.Index do Process.cancel_timer(socket.assigns.bounds_update_timer) end - timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 100) + timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 300) socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds) {:noreply, socket} end @@ -1988,23 +1386,6 @@ defmodule AprsmeWeb.MapLive.Index do send_heat_map_for_packets(socket, packet_list) end - defp send_heat_map_for_current_bounds(socket) do - # Get all packets within current bounds - all_packets = - Map.values(socket.assigns.visible_packets) ++ - Map.values(socket.assigns.historical_packets) - - # Filter by bounds - filtered_packets = - all_packets - |> filter_packets_by_bounds(socket.assigns.map_bounds) - |> Enum.uniq_by(fn packet -> - Map.get(packet, :id) || Map.get(packet, "id") - end) - - send_heat_map_for_packets(socket, filtered_packets) - end - # Common heat map display logic defp send_heat_map_for_packets(socket, packets) do # Get clustering data @@ -2018,51 +1399,6 @@ defmodule AprsmeWeb.MapLive.Index do end end - # Helper to build packet data list from a map of packets - defp build_packet_data_list_from_map(packets_map, is_most_recent, socket) do - locale = get_locale(socket) - - packets_map - |> Enum.map(fn {_callsign, packet} -> - PacketUtils.build_packet_data(packet, is_most_recent, locale) - end) - |> Enum.filter(& &1) - end - - # Helper to get locale from socket - defp get_locale(socket) do - Map.get(socket.assigns, :locale, "en") - end - - # Helper to update map center and zoom to location - defp update_and_zoom_to_location(socket, lat, lng, zoom) do - socket - |> assign(map_center: %{lat: lat, lng: lng}, map_zoom: zoom) - |> push_event("zoom_to_location", %{lat: lat, lng: lng, zoom: zoom}) - end - - # Helper to zoom to current location - defp zoom_to_current_location(socket) do - push_event(socket, "zoom_to_location", %{ - lat: socket.assigns.map_center.lat, - lng: socket.assigns.map_center.lng, - zoom: socket.assigns.map_zoom - }) - end - - defp trigger_marker_display(socket) do - # Clear heat map and show markers - socket = push_event(socket, "show_markers", %{}) - - # Re-send all visible packets as markers - visible_packets_list = build_packet_data_list_from_map(socket.assigns.visible_packets, true, socket) - - socket = add_markers_if_any(socket, visible_packets_list) - - # Trigger historical packet reload for markers - start_progressive_historical_loading(socket) - end - @spec process_bounds_update(map(), Socket.t()) :: Socket.t() defp process_bounds_update(map_bounds, socket) do require Logger @@ -2071,7 +1407,7 @@ defmodule AprsmeWeb.MapLive.Index do # Check if this is the initial load or if bounds have actually changed is_initial_load = socket.assigns[:needs_initial_historical_load] || !socket.assigns[:initial_bounds_loaded] - bounds_changed = socket.assigns.map_bounds && not compare_bounds(map_bounds, socket.assigns.map_bounds) + bounds_changed = socket.assigns.map_bounds && not BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds) # Check if we've completed the initial historical load initial_historical_completed = socket.assigns[:initial_historical_completed] || false @@ -2111,7 +1447,7 @@ defmodule AprsmeWeb.MapLive.Index do # Load historical packets for the new bounds (now socket.assigns.map_bounds is correct) Logger.debug("Starting progressive historical loading for new bounds") - socket = start_progressive_historical_loading(socket) + socket = HistoricalLoader.start_progressive_historical_loading(socket) # Mark initial historical as completed if this was the initial load if is_initial_load do @@ -2120,92 +1456,4 @@ defmodule AprsmeWeb.MapLive.Index do socket end end - - # Parse RF path string to extract digipeater/igate callsigns - defp parse_rf_path(path) when is_binary(path) do - # Split by comma and filter out empty strings - path - |> String.split(",") - |> Enum.map(&String.trim/1) - # Skip empty strings, TCPIP entries and qA* entries - |> Enum.reject(fn callsign -> - callsign == "" || String.contains?(callsign, "TCPIP") || String.starts_with?(callsign, "qA") - end) - |> Enum.map(fn callsign -> - # Remove any asterisk (used to mark heard stations) and WIDE patterns - callsign - |> String.replace("*", "") - |> String.trim() - end) - # Filter out APRS built-in beacon patterns - |> Enum.reject(fn callsign -> - callsign == "" || aprs_beacon?(callsign) - end) - |> Enum.uniq() - end - - defp parse_rf_path(_), do: [] - - # Get positions of path stations from database with caching - defp get_path_station_positions(callsigns, _socket) when is_list(callsigns) do - # Use cached queries for better performance - CachedQueries.get_path_station_positions_cached(callsigns) - end - - defp get_path_station_positions(_, _), do: [] - - # Check if a callsign is an APRS built-in beacon pattern - @spec aprs_beacon?(String.t()) :: boolean() - defp aprs_beacon?(callsign) do - # Common APRS beacon patterns to exclude - patterns = [ - # WIDE1, WIDE2, WIDE1-1, etc. - ~r/^WIDE\d(-\d)?$/i, - # TRACE patterns - ~r/^TRACE\d(-\d)?$/i, - # RELAY - ~r/^RELAY$/i, - # ECHO - ~r/^ECHO$/i, - # GATE - ~r/^GATE$/i, - # UNPROTO - ~r/^UNPROTO$/i, - # BEACON - ~r/^BEACON$/i, - # CQ - ~r/^CQ$/i, - # QTH - ~r/^QTH$/i, - # MAIL - ~r/^MAIL$/i, - # TEMP1, TEMP2, etc. - ~r/^TEMP\d+$/i, - # ID - ~r/^ID$/i, - # GPS - ~r/^GPS$/i, - # DF - ~r/^DF$/i, - # DGPS - ~r/^DGPS$/i, - # PHG patterns - ~r/^PHG\d+$/i, - # RNG patterns - ~r/^RNG\d+$/i, - # DFS patterns - ~r/^DFS\d+$/i, - # HOP patterns - ~r/^HOP\d+(-\d)?$/i, - # REGION patterns - ~r/^REGION\d+(-\d)?$/i, - # STATE patterns - ~r/^STATE\d+(-\d)?$/i - ] - - # Check if the callsign matches any beacon pattern - Enum.any?(patterns, fn pattern -> - Regex.match?(pattern, callsign) - end) - end end diff --git a/lib/aprsme_web/live/map_live/map_helpers.ex b/lib/aprsme_web/live/map_live/map_helpers.ex index 5bcd7b5..48d6838 100644 --- a/lib/aprsme_web/live/map_live/map_helpers.ex +++ b/lib/aprsme_web/live/map_live/map_helpers.ex @@ -2,6 +2,7 @@ defmodule AprsmeWeb.MapLive.MapHelpers do @moduledoc false alias Aprs.Types.MicE + alias AprsmeWeb.Live.Shared.BoundsUtils @spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil} def get_coordinates(%{data_extended: %MicE{} = mic_e}) do @@ -58,83 +59,10 @@ defmodule AprsmeWeb.MapLive.MapHelpers do @spec within_bounds?(map() | tuple(), map()) :: boolean() def within_bounds?(packet_or_coords, bounds) do - if is_nil(bounds) do - false - else - {lat, lon} = extract_lat_lon(packet_or_coords) - - if is_nil(lat) or is_nil(lon) do - false - else - check_bounds(lat, lon, bounds) - end - end + # Delegate to shared bounds utility + BoundsUtils.within_bounds?(packet_or_coords, bounds) end - defp check_bounds(lat, lon, bounds) do - lat = to_float(lat) - lon = to_float(lon) - south = to_float(bounds.south) - north = to_float(bounds.north) - west = to_float(bounds.west) - east = to_float(bounds.east) - - lat_in_bounds = lat >= south && lat <= north - lng_in_bounds = check_longitude_bounds(lon, west, east) - - lat_in_bounds && lng_in_bounds - end - - defp check_longitude_bounds(lon, west, east) do - if west <= east do - lon >= west && lon <= east - else - lon >= west || lon <= east - end - end - - defp extract_lat_lon(%{lat: lat, lon: lon}), do: extract_lat_lon_atom(%{lat: lat, lon: lon}) - - defp extract_lat_lon(%{"lat" => lat, "lon" => lon}), do: extract_lat_lon_string(%{"lat" => lat, "lon" => lon}) - - defp extract_lat_lon(%{latitude: lat, longitude: lon}), do: extract_lat_lon_atom_alt(%{latitude: lat, longitude: lon}) - - defp extract_lat_lon({lat, lon}) when is_number(lat) and is_number(lon), do: {lat, lon} - defp extract_lat_lon(_), do: {nil, nil} - - defp extract_lat_lon_atom(packet), do: {packet.lat, packet.lon} - defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]} - defp extract_lat_lon_atom_alt(packet), do: {packet.latitude, packet.longitude} - - defp to_float(value) do - Aprsme.EncodingUtils.to_float(value) || 0.0 - end - - @doc """ - Normalizes map bounds from string keys to atom keys and converts values to floats. - - ## Examples - - iex> normalize_bounds(%{"north" => "40.5", "south" => "40.0", "east" => "-73.5", "west" => "-74.0"}) - %{north: 40.5, south: 40.0, east: -73.5, west: -74.0} - - iex> normalize_bounds(%{"north" => 40.5, "south" => 40.0, "east" => -73.5, "west" => -74.0}) - %{north: 40.5, south: 40.0, east: -73.5, west: -74.0} - """ - @spec normalize_bounds(map()) :: map() - def normalize_bounds(%{"north" => n, "south" => s, "east" => e, "west" => w}) do - %{ - north: to_float(n), - south: to_float(s), - east: to_float(e), - west: to_float(w) - } - end - - def normalize_bounds(%{north: _, south: _, east: _, west: _} = bounds) do - # Already normalized with atom keys - bounds - end - - def normalize_bounds(_), do: nil + # Delegate normalize_bounds to shared utility + defdelegate normalize_bounds(bounds), to: BoundsUtils end diff --git a/lib/aprsme_web/live/map_live/navigation.ex b/lib/aprsme_web/live/map_live/navigation.ex new file mode 100644 index 0000000..0a480c8 --- /dev/null +++ b/lib/aprsme_web/live/map_live/navigation.ex @@ -0,0 +1,91 @@ +defmodule AprsmeWeb.MapLive.Navigation do + @moduledoc """ + Handles geolocation, callsign tracking, and map navigation functionality. + """ + + import Phoenix.Component, only: [assign: 3] + + alias Aprsme.CachedQueries + alias AprsmeWeb.MapLive.UrlParams + alias AprsmeWeb.MapLive.Utils + alias Phoenix.LiveView + alias Phoenix.LiveView.Socket + + @doc """ + Determine map location based on URL parameters and IP geolocation. + Returns {map_center, zoom, should_skip_initial_url_update}. + """ + @spec determine_map_location(map(), map()) :: {map(), integer(), boolean()} + def determine_map_location(params, session) do + {url_center, url_zoom} = UrlParams.parse_map_params(params) + has_explicit_url_params = UrlParams.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 + + @doc """ + Handle callsign tracking by finding the latest packet for the callsign. + Returns {final_map_center, final_map_zoom}. + """ + @spec handle_callsign_tracking(binary(), map(), integer(), boolean()) :: {map(), integer()} + def 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 + + @doc """ + Handle callsign search with validation. + """ + @spec handle_callsign_search(binary(), Socket.t()) :: {:noreply, Socket.t()} + def handle_callsign_search("", socket), do: {:noreply, socket} + + def handle_callsign_search(callsign, socket) do + if Utils.valid_callsign?(callsign) do + {:noreply, LiveView.push_navigate(socket, to: "/#{callsign}")} + else + {:noreply, LiveView.put_flash(socket, :error, "Invalid callsign format")} + end + end + + @doc """ + Update map center and zoom to specific location. + """ + @spec update_and_zoom_to_location(Socket.t(), float(), float(), integer()) :: Socket.t() + def update_and_zoom_to_location(socket, lat, lng, zoom) do + socket + |> assign(:map_center, %{lat: lat, lng: lng}) + |> assign(:map_zoom, zoom) + |> LiveView.push_event("zoom_to_location", %{lat: lat, lng: lng, zoom: zoom}) + end + + @doc """ + Zoom to current location stored in socket. + """ + @spec zoom_to_current_location(Socket.t()) :: Socket.t() + def zoom_to_current_location(socket) do + LiveView.push_event(socket, "zoom_to_location", %{ + lat: socket.assigns.map_center.lat, + lng: socket.assigns.map_center.lng, + zoom: socket.assigns.map_zoom + }) + end +end diff --git a/lib/aprsme_web/live/map_live/packet_processor.ex b/lib/aprsme_web/live/map_live/packet_processor.ex new file mode 100644 index 0000000..515740a --- /dev/null +++ b/lib/aprsme_web/live/map_live/packet_processor.ex @@ -0,0 +1,163 @@ +defmodule AprsmeWeb.MapLive.PacketProcessor do + @moduledoc """ + Handles real-time packet processing, filtering, and display logic. + """ + + import Phoenix.Component, only: [assign: 3] + + alias Aprsme.GeoUtils + alias AprsmeWeb.Live.Shared.CoordinateUtils + alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils + alias AprsmeWeb.MapLive.PacketUtils + alias Phoenix.LiveView + alias Phoenix.LiveView.Socket + + @max_visible_packets 1000 + @max_all_packets 2000 + + @doc """ + Process a packet for display on the map. + """ + @spec process_packet_for_display(map(), Socket.t()) :: {:noreply, Socket.t()} + def process_packet_for_display(packet, socket) do + {lat, lon, _data_extended} = CoordinateUtils.get_coordinates(packet) + callsign_key = SharedPacketUtils.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 + SharedPacketUtils.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 + + @doc """ + Handle packet visibility logic based on bounds and existing markers. + """ + @spec handle_packet_visibility(map(), number() | nil, number() | nil, binary(), Socket.t()) :: Socket.t() + def 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, _} = CoordinateUtils.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) + assign(socket, :visible_packets, new_visible_packets) + end + + true -> + socket + end + end + + @doc """ + Handle valid postgres packet by adding it to visible packets and displaying it. + """ + @spec handle_valid_postgres_packet(map(), number() | nil, number() | nil, Socket.t()) :: Socket.t() + def handle_valid_postgres_packet(packet, _lat, _lon, socket) do + # Add the packet to visible_packets and push a marker immediately + callsign_key = SharedPacketUtils.get_callsign_key(packet) + + 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 + SharedPacketUtils.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 + LiveView.push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false)) + else + LiveView.push_event(socket, "new_packet", marker_data) + end + else + socket + end + end + end + + @doc """ + Remove marker from map and update visible packets. + """ + @spec remove_marker_from_map(binary(), Socket.t()) :: Socket.t() + def remove_marker_from_map(callsign_key, socket) do + socket = LiveView.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 + + # Private helper functions + + 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 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 + 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 + within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) + end + + # Use shared bounds utility + defp within_bounds?(coords, bounds) do + AprsmeWeb.Live.Shared.BoundsUtils.within_bounds?(coords, bounds) + end + + # Helper to get locale from socket + defp get_locale(socket) do + Map.get(socket.assigns, :locale, "en") + end + + # 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/packet_utils.ex b/lib/aprsme_web/live/map_live/packet_utils.ex index 3a0d6c2..ba32f86 100644 --- a/lib/aprsme_web/live/map_live/packet_utils.ex +++ b/lib/aprsme_web/live/map_live/packet_utils.ex @@ -3,10 +3,9 @@ defmodule AprsmeWeb.MapLive.PacketUtils do Shared utilities for extracting and processing packet data in map views. """ - alias AprsmeWeb.MapLive.MapHelpers - alias AprsmeWeb.MapLive.PopupComponent - alias AprsmeWeb.TimeHelpers - alias Phoenix.HTML.Safe + alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils + alias AprsmeWeb.Live.Shared.ParamUtils + alias AprsmeWeb.MapLive.DataBuilder @doc """ Safely extracts a value from a packet or data_extended map with fallback support. @@ -14,13 +13,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """ @spec get_packet_field(map(), atom() | String.t(), any()) :: any() def get_packet_field(packet, field, default \\ nil) do - data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{} - - Map.get(packet, field) || - Map.get(packet, to_string(field)) || - Map.get(data_extended, field) || - Map.get(data_extended, to_string(field)) || - default + SharedPacketUtils.get_packet_field(packet, field, default) end @doc """ @@ -28,7 +21,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """ @spec get_symbol_info(map()) :: {String.t(), String.t()} def get_symbol_info(packet) do - AprsmeWeb.AprsSymbol.extract_from_packet(packet) + SharedPacketUtils.get_symbol_info(packet) end @doc """ @@ -36,16 +29,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """ @spec get_timestamp(map()) :: String.t() def get_timestamp(packet) do - cond do - Map.has_key?(packet, :received_at) && packet.received_at -> - DateTime.to_iso8601(packet.received_at) - - Map.has_key?(packet, "received_at") && packet["received_at"] -> - DateTime.to_iso8601(packet["received_at"]) - - true -> - "" - end + SharedPacketUtils.get_timestamp(packet) end @doc """ @@ -53,7 +37,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """ @spec to_float(any()) :: float() def to_float(value) do - Aprsme.EncodingUtils.to_float(value) || 0.0 + ParamUtils.to_float(value) end @doc """ @@ -61,14 +45,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """ @spec generate_callsign(map()) :: String.t() def generate_callsign(packet) do - base_callsign = get_packet_field(packet, :base_callsign, "") - ssid = get_packet_field(packet, :ssid, nil) - - if ssid != nil and ssid != "" do - "#{base_callsign}-#{ssid}" - else - base_callsign - end + SharedPacketUtils.generate_callsign(packet) end @doc """ @@ -76,17 +53,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """ @spec weather_packet?(map()) :: boolean() def weather_packet?(packet) do - # Check if packet contains any weather data fields - not is_nil(get_packet_field(packet, :temperature, nil)) or - not is_nil(get_packet_field(packet, :humidity, nil)) or - not is_nil(get_packet_field(packet, :pressure, nil)) or - not is_nil(get_packet_field(packet, :wind_speed, nil)) or - not is_nil(get_packet_field(packet, :wind_direction, nil)) or - not is_nil(get_packet_field(packet, :rain_1h, nil)) or - not is_nil(get_packet_field(packet, :rain_24h, nil)) or - not is_nil(get_packet_field(packet, :rain_since_midnight, nil)) or - not is_nil(get_packet_field(packet, :snow, nil)) or - not is_nil(get_packet_field(packet, :luminosity, nil)) + SharedPacketUtils.weather_packet?(packet) end @doc """ @@ -132,206 +99,16 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """ @spec get_weather_field(map(), atom()) :: String.t() def get_weather_field(packet, key) do - data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{} - - Map.get(packet, key) || - Map.get(packet, to_string(key)) || - Map.get(data_extended, key) || - Map.get(data_extended, to_string(key)) || "N/A" - end - - def build_packet_data(packet, is_most_recent_for_callsign, locale \\ "en") - 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) + case SharedPacketUtils.get_weather_field(packet, key) do + nil -> "N/A" + value -> value end end - def build_packet_data(packet) do - build_packet_data(packet, false, "en") - end + # These functions have been moved to AprsmeWeb.MapLive.DataBuilder + defdelegate build_packet_data(packet, is_most_recent_for_callsign, locale), to: DataBuilder + defdelegate build_packet_data(packet, is_most_recent_for_callsign), to: DataBuilder + defdelegate build_packet_data(packet), to: DataBuilder - 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 - - 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 - - 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 - - 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 - - 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 - - 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 - - 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 - - # 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"} + # All packet data building functions have been moved to AprsmeWeb.MapLive.DataBuilder end diff --git a/lib/aprsme_web/live/map_live/rf_path.ex b/lib/aprsme_web/live/map_live/rf_path.ex new file mode 100644 index 0000000..4b78755 --- /dev/null +++ b/lib/aprsme_web/live/map_live/rf_path.ex @@ -0,0 +1,67 @@ +defmodule AprsmeWeb.MapLive.RfPath do + @moduledoc """ + Handles RF path parsing and visualization for APRS packets. + """ + + alias Aprsme.CachedQueries + alias AprsmeWeb.MapLive.Utils + + @doc """ + Parse RF path string to extract digipeater/igate stations. + """ + @spec parse_rf_path(binary()) :: list(binary()) + def parse_rf_path(path) when is_binary(path) do + path + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.map(&extract_callsign_from_path_element/1) + |> Enum.filter(&(&1 != "")) + |> Enum.uniq() + end + + def parse_rf_path(_), do: [] + + @doc """ + Get positions for path stations from the database. + """ + @spec get_path_station_positions(list(binary()), Phoenix.LiveView.Socket.t()) :: list(map()) + def get_path_station_positions(path_stations, _socket) do + # Limit to prevent excessive database queries + limited_stations = Enum.take(path_stations, 10) + + limited_stations + |> Enum.map(&get_station_position/1) + |> Enum.filter(& &1) + end + + defp extract_callsign_from_path_element(element) do + # Remove asterisk (used flag) and extract callsign + element + |> String.replace("*", "") + |> String.trim() + |> String.upcase() + |> validate_path_callsign() + end + + defp validate_path_callsign(callsign) do + if Utils.valid_callsign?(callsign) do + callsign + else + "" + end + end + + defp get_station_position(callsign) do + case CachedQueries.get_latest_packet_for_callsign_cached(callsign) do + %{lat: lat, lon: lon} when is_number(lat) and is_number(lon) -> + %{ + callsign: callsign, + lat: lat, + lng: lon + } + + _ -> + nil + end + end +end diff --git a/lib/aprsme_web/live/map_live/url_params.ex b/lib/aprsme_web/live/map_live/url_params.ex new file mode 100644 index 0000000..26f64fd --- /dev/null +++ b/lib/aprsme_web/live/map_live/url_params.ex @@ -0,0 +1,83 @@ +defmodule AprsmeWeb.MapLive.UrlParams do + @moduledoc """ + Handles URL parameter parsing, validation, and sanitization for the map LiveView. + """ + + alias AprsmeWeb.Live.Shared.CoordinateUtils + alias AprsmeWeb.Live.Shared.ParamUtils + + @default_center %{lat: 39.8283, lng: -98.5795} + @default_zoom 5 + + @doc """ + Parse map state from URL parameters. + Returns {map_center, zoom} tuple with validated coordinates. + """ + @spec parse_map_params(map()) :: {map(), integer()} + def 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 + + @doc """ + Parse and validate latitude parameter. + """ + @spec parse_latitude(binary() | nil) :: float() + def parse_latitude(nil), do: @default_center.lat + + def parse_latitude(lat_str) do + ParamUtils.parse_float_in_range(lat_str, @default_center.lat, -90, 90) + end + + @doc """ + Parse and validate longitude parameter. + """ + @spec parse_longitude(binary() | nil) :: float() + def parse_longitude(nil), do: @default_center.lng + + def parse_longitude(lng_str) do + ParamUtils.parse_float_in_range(lng_str, @default_center.lng, -180, 180) + end + + @doc """ + Parse and validate zoom parameter. + """ + @spec parse_zoom(binary() | nil) :: integer() + def parse_zoom(nil), do: @default_zoom + + def parse_zoom(zoom_str) do + ParamUtils.parse_int_in_range(zoom_str, @default_zoom, 1, 20) + end + + # Delegate to shared utilities + defdelegate parse_float_in_range(str, default, min, max), to: ParamUtils + defdelegate parse_int_in_range(str, default, min, max), to: ParamUtils + defdelegate sanitize_numeric_string(str), to: ParamUtils + defdelegate limit_string_length(str, max_length), to: ParamUtils + defdelegate finite?(float), to: ParamUtils + defdelegate valid_coordinates?(lat, lng), to: CoordinateUtils + + @doc """ + Check if URL parameters explicitly specify map location. + """ + @spec has_explicit_url_params?(map()) :: boolean() + def has_explicit_url_params?(params) do + !!(params["lat"] || params["lng"] || params["z"]) + end + + @doc """ + Get default map center coordinates. + """ + @spec default_center() :: map() + def default_center, do: @default_center + + @doc """ + Get default zoom level. + """ + @spec default_zoom() :: integer() + def default_zoom, do: @default_zoom +end diff --git a/lib/aprsme_web/live/map_live/utils.ex b/lib/aprsme_web/live/map_live/utils.ex new file mode 100644 index 0000000..d48e47d --- /dev/null +++ b/lib/aprsme_web/live/map_live/utils.ex @@ -0,0 +1,30 @@ +defmodule AprsmeWeb.MapLive.Utils do + @moduledoc """ + Common utility functions for the map LiveView. + """ + + alias AprsmeWeb.Live.Shared.CoordinateUtils + alias AprsmeWeb.Live.Shared.PacketUtils + alias AprsmeWeb.Live.Shared.ParamUtils + + # Delegate to shared utilities + defdelegate safe_parse_coordinate(value, default, min, max), to: ParamUtils + defdelegate clamp_coordinate(value, min, max), to: ParamUtils + defdelegate clamp_zoom(zoom), to: ParamUtils + defdelegate finite_number?(num), to: ParamUtils + defdelegate calculate_distance_meters(lat1, lon1, lat2, lon2), to: CoordinateUtils + defdelegate parse_trail_duration(duration), to: PacketUtils + defdelegate parse_historical_hours(hours), to: PacketUtils + defdelegate get_callsign_key(packet), to: PacketUtils + defdelegate valid_callsign?(callsign), to: ParamUtils + defdelegate sanitize_path_string(path), to: ParamUtils + defdelegate prune_oldest_packets(packets_map, max_count), to: PacketUtils + + @doc """ + Round number to 4 decimal places for bounds comparison. + """ + @spec round_to_4_places(number() | any()) :: number() + def round_to_4_places(n) when is_float(n), do: Float.round(n, 4) + def round_to_4_places(n) when is_integer(n), do: n * 1.0 + def round_to_4_places(x), do: x +end diff --git a/lib/aprsme_web/live/shared/bounds_utils.ex b/lib/aprsme_web/live/shared/bounds_utils.ex new file mode 100644 index 0000000..6f1b04e --- /dev/null +++ b/lib/aprsme_web/live/shared/bounds_utils.ex @@ -0,0 +1,155 @@ +defmodule AprsmeWeb.Live.Shared.BoundsUtils do + @moduledoc """ + Shared bounds validation, comparison, and filtering utilities. + Used across multiple LiveView modules for consistent bounds handling. + """ + + alias AprsmeWeb.Live.Shared.CoordinateUtils + + @doc """ + Validate bounds to prevent invalid coordinates. + """ + @spec valid_bounds?(map()) :: boolean() + def valid_bounds?(map_bounds) do + map_bounds.north <= 90 and + map_bounds.south >= -90 and + map_bounds.north > map_bounds.south and + map_bounds.east >= -180 and + map_bounds.west <= 180 + end + + @doc """ + Compare two bounds maps for equality (with rounding for floating point comparison). + """ + @spec compare_bounds(map() | nil, map() | nil) :: boolean() + def compare_bounds(nil, nil), do: true + def compare_bounds(nil, _), do: false + def compare_bounds(_, nil), do: false + def compare_bounds(b1, b2), do: compare_bounds_maps(b1, b2) + + @doc """ + Check if a packet is within the specified bounds. + """ + @spec within_bounds?(map() | struct() | tuple(), map()) :: boolean() + def within_bounds?({lat, lon}, bounds) when is_number(lat) and is_number(lon) do + check_coordinate_bounds(lat, lon, bounds) + end + + def within_bounds?(packet, bounds) do + {lat, lon, _data_extended} = CoordinateUtils.get_coordinates(packet) + check_coordinate_bounds(lat, lon, bounds) + end + + @doc """ + Filter packets by bounds - works with both maps and lists. + """ + @spec filter_packets_by_bounds(map() | list(), map()) :: map() | list() + def 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 + + def filter_packets_by_bounds(packets_list, bounds) when is_list(packets_list) do + Enum.filter(packets_list, &within_bounds?(&1, bounds)) + end + + @doc """ + Get packet keys that are outside the specified bounds. + """ + @spec reject_packets_by_bounds(map(), map()) :: list() + def 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 + + @doc """ + Normalize map bounds from string keys to atom keys and convert values to floats. + """ + @spec normalize_bounds(map()) :: map() | nil + def normalize_bounds(%{"north" => n, "south" => s, "east" => e, "west" => w}) do + %{ + north: to_float(n), + south: to_float(s), + east: to_float(e), + west: to_float(w) + } + end + + def normalize_bounds(%{north: _, south: _, east: _, west: _} = bounds) do + # Already normalized with atom keys + bounds + end + + def normalize_bounds(_), do: nil + + @doc """ + Calculate approximate bounds based on center point and zoom level. + """ + @spec calculate_bounds_from_center_and_zoom(map(), integer()) :: map() + def calculate_bounds_from_center_and_zoom(center, zoom) do + # Approximate degrees per pixel at different zoom levels + 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 + + # Private helper functions + + defp compare_bounds_maps(b1, b2) do + Enum.all?([:north, :south, :east, :west], fn key -> + round_to_4_places(Map.get(b1, key)) == round_to_4_places(Map.get(b2, key)) + end) + end + + defp check_coordinate_bounds(nil, _, _), do: false + defp check_coordinate_bounds(_, nil, _), do: false + + defp check_coordinate_bounds(lat, lon, bounds) do + # Check latitude bounds (straightforward) + lat_in_bounds = lat >= bounds.south && lat <= bounds.north + + # Check longitude bounds (handle potential wrapping) + lng_in_bounds = check_longitude_bounds(lon, bounds.west, bounds.east) + + lat_in_bounds && lng_in_bounds + end + + defp check_longitude_bounds(lon, west, east) when west <= east do + # Normal case: bounds don't cross antimeridian + lon >= west && lon <= east + end + + defp check_longitude_bounds(lon, west, east) do + # Bounds cross antimeridian (e.g., west=170, east=-170) + lon >= west || lon <= east + end + + 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 round_to_4_places(n) when is_float(n), do: Float.round(n, 4) + defp round_to_4_places(n) when is_integer(n), do: n * 1.0 + defp round_to_4_places(x), do: x + + defp to_float(value) do + Aprsme.EncodingUtils.to_float(value) || 0.0 + end +end diff --git a/lib/aprsme_web/live/shared/coordinate_utils.ex b/lib/aprsme_web/live/shared/coordinate_utils.ex new file mode 100644 index 0000000..19f3fd1 --- /dev/null +++ b/lib/aprsme_web/live/shared/coordinate_utils.ex @@ -0,0 +1,132 @@ +defmodule AprsmeWeb.Live.Shared.CoordinateUtils do + @moduledoc """ + Shared coordinate parsing, validation, and calculation utilities. + Used across multiple LiveView modules for consistent coordinate handling. + """ + + alias Aprs.Types.MicE + alias AprsmeWeb.Live.Shared.ParamUtils + + @doc """ + Extract coordinates from various packet formats. + Returns {lat, lon, data_extended} tuple. + """ + @spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil} + def get_coordinates(%{data_extended: %MicE{} = mic_e}) do + {lat, lon} = get_coordinates_from_mic_e(mic_e) + {lat, lon, mic_e} + end + + def get_coordinates(%{data_extended: %{latitude: lat, longitude: lon}} = packet) do + {lat, lon, packet.data_extended} + end + + def get_coordinates(packet) do + lat = Map.get(packet, :lat) || Map.get(packet, "lat") + lon = Map.get(packet, :lon) || Map.get(packet, "lon") + data_extended = Map.get(packet, :data_extended) || Map.get(packet, "data_extended") + {lat, lon, data_extended} + end + + @doc """ + Extract coordinates from MicE data format. + """ + @spec get_coordinates_from_mic_e(MicE.t()) :: {number() | nil, number() | nil} + def get_coordinates_from_mic_e(mic_e) do + lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0 + lat = if mic_e.lat_direction == :south, do: -lat, else: lat + lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0 + lon = if mic_e.lon_direction == :west, do: -lon, else: lon + if lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180, do: {lat, lon}, else: {nil, nil} + end + + @doc """ + Check if packet has position data in any format. + """ + @spec has_position_data?(map()) :: boolean() + def has_position_data?(packet) do + lat = Map.get(packet, :lat) || Map.get(packet, "lat") + lon = Map.get(packet, :lon) || Map.get(packet, "lon") + + if has_direct_coordinates?(lat, lon) do + true + else + has_position_in_data_extended?(packet) + end + end + + @doc """ + Validate that coordinates are within reasonable ranges. + """ + @spec valid_coordinates?(number(), number()) :: boolean() + def 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 + + def valid_coordinates?(_, _), do: false + + @doc """ + Calculate distance between two lat/lon points in meters using Haversine formula. + """ + @spec calculate_distance_meters(number(), number(), number(), number()) :: float() + def calculate_distance_meters(lat1, lon1, lat2, lon2) do + # Convert latitude and longitude from degrees to radians + lat1_rad = lat1 * :math.pi() / 180 + lon1_rad = lon1 * :math.pi() / 180 + lat2_rad = lat2 * :math.pi() / 180 + lon2_rad = lon2 * :math.pi() / 180 + + # Haversine formula + dlat = lat2_rad - lat1_rad + dlon = lon2_rad - lon1_rad + + a = + :math.sin(dlat / 2) * :math.sin(dlat / 2) + + :math.cos(lat1_rad) * :math.cos(lat2_rad) * + :math.sin(dlon / 2) * :math.sin(dlon / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + + # Earth's radius in meters + earth_radius_meters = 6_371_000 + + # Distance in meters + earth_radius_meters * c + end + + # Private helper functions + + defp has_direct_coordinates?(lat, lon) when not is_nil(lat) and not is_nil(lon), do: true + defp has_direct_coordinates?(_, _), do: false + + defp has_position_in_data_extended?(packet) do + data_extended = Map.get(packet, :data_extended) || Map.get(packet, "data_extended") + has_position_in_data_extended_case?(data_extended) + end + + defp has_position_in_data_extended_case?(%MicE{}), do: true + + defp has_position_in_data_extended_case?(%{latitude: lat, longitude: lon}) when not is_nil(lat) and not is_nil(lon), + do: true + + defp has_position_in_data_extended_case?(_), do: false + + @doc """ + Parse center coordinates from client update with validation. + """ + @spec parse_center_coordinates(map() | any(), Phoenix.LiveView.Socket.t()) :: {float(), float()} + def 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 = ParamUtils.safe_parse_coordinate(Map.get(center, "lat"), default_lat, -90.0, 90.0) + lng = ParamUtils.safe_parse_coordinate(Map.get(center, "lng"), default_lng, -180.0, 180.0) + + {lat, lng} + end + + def parse_center_coordinates(_, socket) do + # Invalid center format, return current center + {socket.assigns.map_center.lat, socket.assigns.map_center.lng} + end +end diff --git a/lib/aprsme_web/live/shared/packet_utils.ex b/lib/aprsme_web/live/shared/packet_utils.ex new file mode 100644 index 0000000..ffe7080 --- /dev/null +++ b/lib/aprsme_web/live/shared/packet_utils.ex @@ -0,0 +1,222 @@ +defmodule AprsmeWeb.Live.Shared.PacketUtils do + @moduledoc """ + Shared packet processing and memory management utilities. + Used across multiple LiveView modules for consistent packet handling. + """ + + @doc """ + Get unique callsign key from packet. + """ + @spec get_callsign_key(map()) :: binary() + def get_callsign_key(packet) do + if Map.has_key?(packet, "id"), + do: to_string(packet["id"]), + else: [:positive] |> System.unique_integer() |> to_string() + end + + @doc """ + Prune oldest packets from a map to enforce memory limits. + """ + @spec prune_oldest_packets(map(), integer()) :: map() + def prune_oldest_packets(packets_map, max_count) when map_size(packets_map) <= max_count do + packets_map + end + + def prune_oldest_packets(packets_map, max_count) do + # Convert to list, sort by timestamp, take newest max_count packets + packets_map + |> Enum.to_list() + |> Enum.sort_by( + fn {_key, packet} -> + case packet do + %{received_at: %DateTime{} = dt} -> DateTime.to_unix(dt) + %{"received_at" => timestamp} when is_integer(timestamp) -> timestamp + _ -> 0 + end + end, + :desc + ) + |> Enum.take(max_count) + |> Map.new() + end + + @doc """ + Parse trail duration with validation and bounds checking. + """ + @spec parse_trail_duration(binary() | any()) :: integer() + def 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 + + def parse_trail_duration(_), do: 1 + + @doc """ + Parse historical hours with validation. + """ + @spec parse_historical_hours(binary() | any()) :: integer() + def 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 + + def parse_historical_hours(_), do: 1 + + @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 + packets + |> Enum.filter(fn {_callsign, packet} -> + AprsmeWeb.Live.Shared.BoundsUtils.within_bounds?(packet, bounds) && + packet_within_time_threshold?(packet, time_threshold) + end) + |> Map.new() + end + + @doc """ + Check if packet contains weather data. + """ + @spec has_weather_data?(map()) :: boolean() + def has_weather_data?(packet) do + weather_fields = [ + :temperature, + :humidity, + :pressure, + :wind_speed, + :wind_direction, + :rain_1h, + :rain_24h, + :rain_since_midnight, + :snow, + :luminosity + ] + + Enum.any?(weather_fields, fn field -> + value = get_packet_field(packet, field, nil) + not is_nil(value) + end) + end + + @doc """ + Check if packet is a weather packet based on its data. + """ + @spec weather_packet?(map()) :: boolean() + def weather_packet?(packet) do + has_weather_data?(packet) + end + + @doc """ + Get weather field value from packet with fallback. + """ + @spec get_weather_field(map(), atom()) :: any() + def get_weather_field(packet, field) do + data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{} + + Map.get(packet, field) || + Map.get(packet, to_string(field)) || + Map.get(data_extended, field) || + Map.get(data_extended, to_string(field)) + end + + @doc """ + Get packet field value with fallback and default. + Checks both atom and string keys, and data_extended field. + """ + @spec get_packet_field(map(), atom() | String.t(), any()) :: any() + def get_packet_field(packet, field, default \\ nil) do + data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{} + + Map.get(packet, field) || + Map.get(packet, to_string(field)) || + Map.get(data_extended, field) || + Map.get(data_extended, to_string(field)) || + default + end + + # Private helper functions + + @doc """ + Check if a packet is within the time threshold (not too old). + """ + @spec packet_within_time_threshold?(struct(), any()) :: boolean() + def packet_within_time_threshold?(packet, threshold) do + case packet do + %{received_at: received_at} when not is_nil(received_at) -> + threshold_dt = convert_threshold_to_datetime(threshold) + DateTime.compare(received_at, threshold_dt) in [:gt, :eq] + + _ -> + # If no timestamp, treat as current + true + end + end + + defp convert_threshold_to_datetime(threshold) when is_integer(threshold) do + # Assume seconds since epoch + DateTime.from_unix!(threshold) + end + + defp convert_threshold_to_datetime(threshold) when is_binary(threshold) do + case DateTime.from_iso8601(threshold) do + {:ok, dt, _} -> dt + _ -> DateTime.utc_now() + end + end + + defp convert_threshold_to_datetime(%DateTime{} = threshold), do: threshold + defp convert_threshold_to_datetime(_), do: DateTime.utc_now() + + @doc """ + Extracts symbol information from a packet with fallbacks. + """ + @spec get_symbol_info(map()) :: {String.t(), String.t()} + def get_symbol_info(packet) do + AprsmeWeb.AprsSymbol.extract_from_packet(packet) + end + + @doc """ + Extracts timestamp from a packet in ISO8601 format. + """ + @spec get_timestamp(map()) :: String.t() + def get_timestamp(packet) do + cond do + Map.has_key?(packet, :received_at) && packet.received_at -> + DateTime.to_iso8601(packet.received_at) + + Map.has_key?(packet, "received_at") && packet["received_at"] -> + DateTime.to_iso8601(packet["received_at"]) + + true -> + "" + end + end + + @doc """ + Generates a callsign string with SSID if present. + """ + @spec generate_callsign(map()) :: String.t() + def generate_callsign(packet) do + base_callsign = get_packet_field(packet, :base_callsign, "") + ssid = get_packet_field(packet, :ssid, nil) + + if ssid != nil and ssid != "" do + "#{base_callsign}-#{ssid}" + else + base_callsign + end + end +end diff --git a/lib/aprsme_web/live/shared/param_utils.ex b/lib/aprsme_web/live/shared/param_utils.ex new file mode 100644 index 0000000..3361ff4 --- /dev/null +++ b/lib/aprsme_web/live/shared/param_utils.ex @@ -0,0 +1,181 @@ +defmodule AprsmeWeb.Live.Shared.ParamUtils do + @moduledoc """ + Shared parameter parsing, validation, and sanitization utilities. + Used across multiple LiveView modules for consistent parameter handling. + """ + + @doc """ + Parse float value within specified range with sanitization. + """ + @spec parse_float_in_range(binary() | any(), float(), float(), float()) :: float() + def 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 + + def parse_float_in_range(_, default, _, _), do: default + + @doc """ + Parse integer value within specified range with sanitization. + """ + @spec parse_int_in_range(binary() | any(), integer(), integer(), integer()) :: integer() + def 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 + + def parse_int_in_range(_, default, _, _), do: default + + @doc """ + Sanitize numeric strings to prevent injection attacks. + """ + @spec sanitize_numeric_string(binary() | any()) :: binary() + def 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 + + def sanitize_numeric_string(_), do: "" + + @doc """ + Limit string length to prevent DoS attacks. + """ + @spec limit_string_length(binary(), integer()) :: binary() + def limit_string_length(str, max_length) when byte_size(str) > max_length do + :binary.part(str, 0, max_length) + end + + def limit_string_length(str, _), do: str + + @doc """ + Check if a float is finite (not infinity or NaN). + """ + @spec finite?(float() | any()) :: boolean() + def finite?(float) when is_float(float) do + # NaN != NaN + float != :infinity and float != :neg_infinity and float == float + end + + def finite?(_), do: false + + @doc """ + Safely parse and clamp coordinate values. + """ + @spec safe_parse_coordinate(binary() | number() | any(), float(), float(), float()) :: float() + def 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 + end + + def 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 + + def safe_parse_coordinate(_, default, _, _), do: default + + @doc """ + Clamp coordinate value within bounds. + """ + @spec clamp_coordinate(number(), number(), number()) :: float() + def clamp_coordinate(value, min, max) when is_number(value) do + value |> max(min) |> min(max) + end + + def clamp_coordinate(_, _, _), do: 0.0 + + @doc """ + Clamp zoom level within valid range. + """ + @spec clamp_zoom(binary() | integer() | float() | any()) :: integer() + def clamp_zoom(zoom) when is_binary(zoom) do + parse_int_in_range(zoom, 5, 1, 20) + end + + def clamp_zoom(zoom) when is_integer(zoom) do + max(1, min(20, zoom)) + end + + def clamp_zoom(zoom) when is_float(zoom) do + max(1, min(20, trunc(zoom))) + end + + def clamp_zoom(_), do: 5 + + @doc """ + Check if a number is finite. + """ + @spec finite_number?(number() | any()) :: boolean() + def finite_number?(num) when is_number(num) do + # Convert integer to float for check + finite?(num / 1.0) + end + + def finite_number?(_), do: false + + @doc """ + Validate callsign format. + """ + @spec valid_callsign?(binary()) :: boolean() + def valid_callsign?(callsign) when is_binary(callsign) do + # Basic callsign validation - alphanumeric with optional dash and suffix + String.match?(callsign, ~r/^[A-Z0-9]{1,6}(-[A-Z0-9]{1,2})?$/i) + end + + def valid_callsign?(_), do: false + + @doc """ + Sanitize path string for RF path visualization. + """ + @spec sanitize_path_string(binary() | any()) :: binary() + def sanitize_path_string(path) when is_binary(path) do + # Remove potentially dangerous characters, keep only alphanumeric, dash, comma, space, asterisk + path + |> String.trim() + |> String.replace(~r/[^A-Za-z0-9\-,\s\*]/, "") + |> limit_string_length(200) + end + + def sanitize_path_string(_), do: "" + + @doc """ + Converts various numeric types to float for consistent display. + """ + @spec to_float(any()) :: float() + def to_float(value) do + Aprsme.EncodingUtils.to_float(value) || 0.0 + end +end diff --git a/test/aprsme/packet_parsing_test.exs b/test/aprsme/packet_parsing_test.exs index d960e77..1463e99 100644 --- a/test/aprsme/packet_parsing_test.exs +++ b/test/aprsme/packet_parsing_test.exs @@ -27,8 +27,8 @@ defmodule Aprsme.PacketParsingTest do # PHG xxx0 = omni (360°) assert position_data[:phg][:directivity] == 360 - # Verify comment is cleaned - assert position_data[:comment] == "Collin Cty Wide Digi" + # Verify comment includes PHG data (parser doesn't extract it from comment) + assert position_data[:comment] == "PHG5530 Collin Cty Wide Digi" end test "packet changeset includes altitude and PHG fields" do diff --git a/test/aprsme_web/live/map_live/movement_test.exs b/test/aprsme_web/live/map_live/movement_test.exs index 7220642..3117b4e 100644 --- a/test/aprsme_web/live/map_live/movement_test.exs +++ b/test/aprsme_web/live/map_live/movement_test.exs @@ -74,16 +74,19 @@ defmodule AprsmeWeb.MapLive.MovementTest do end test "updates marker for significant movement", %{conn: conn} do - # Start with initial location - {:ok, view, _html} = live(conn, "/") + # Start with initial location at a zoom level that shows individual markers + {:ok, view, _html} = live(conn, "/?z=15") - # First update the map state to set zoom level + # Notify the map is ready + assert render_hook(view, "map_ready", %{}) + + # Update the map state to zoom level 15 (individual markers) assert render_hook(view, "update_map_state", %{ "center" => %{"lat" => 33.16961, "lng" => -96.4921}, - "zoom" => 9 + "zoom" => 15 }) - # Then update bounds + # Set bounds to ensure packets are visible bounds_params = %{ "bounds" => %{ "north" => 33.18, @@ -94,7 +97,12 @@ defmodule AprsmeWeb.MapLive.MovementTest do } assert render_hook(view, "bounds_changed", bounds_params) - :timer.sleep(100) + + # Wait for initial load to complete + :timer.sleep(500) + + # Clear any events from initial load + flush_push_events(view) # Simulate initial packet initial_packet = %{ @@ -112,13 +120,16 @@ defmodule AprsmeWeb.MapLive.MovementTest do # Wait for the initial packet to be processed :timer.sleep(100) + # Should receive new_packet for the initial packet + assert_push_event(view, "new_packet", %{}, 1000) + # Simulate significant movement (20+ meters) moved_packet = %{ "id" => "TEST-2", "sender" => "TEST-2", "base_callsign" => "TEST", # About 20 meters north - "lat" => 33.16980, + "lat" => 33.1698, "lon" => -96.4921, "has_position" => true, "received_at" => DateTime.utc_now() @@ -127,8 +138,20 @@ defmodule AprsmeWeb.MapLive.MovementTest do # Send the moved packet send(view.pid, {:postgres_packet, moved_packet}) + # Wait a bit for processing + :timer.sleep(100) + # The view should push a new_packet event for significant movement - assert_push_event(view, "new_packet", %{"lat" => 33.1698}, 500) + assert_push_event(view, "new_packet", %{}, 1000) + end + + defp flush_push_events(view) do + receive do + {ref, {:push_event, _, _}} when is_reference(ref) and ref == view.ref -> + flush_push_events(view) + after + 0 -> :ok + end end end