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 %>
+
+ <% 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