Merge pull request #13 from aprsme/improve-aprs-map-features

Improve APRS map with beacon filtering, error handling, and performance optimizations
This commit is contained in:
Graham McIntire 2025-07-14 12:14:56 -05:00 committed by GitHub
commit 9b76a2ca79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2656 additions and 1298 deletions

18
.kiro/steering/product.md Normal file
View file

@ -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.

115
.kiro/steering/structure.md Normal file
View file

@ -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

100
.kiro/steering/tech.md Normal file
View file

@ -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`

View file

@ -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,

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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