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"), aprs_is_password: System.get_env("APRS_PASSCODE"),
auto_migrate: true, auto_migrate: true,
env: config_env(), 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 # GenStage packet processing configuration
packet_pipeline: [ packet_pipeline: [
max_buffer_size: 1000, max_buffer_size: 1000,

View file

@ -84,7 +84,7 @@ defmodule Aprsme.EncodingUtils do
def sanitize_string(other), do: other def sanitize_string(other), do: other
@doc """ @doc """
Converts various types to float for consistent display. Converts various types to float with validation for safety.
## Examples ## Examples
@ -100,19 +100,50 @@ defmodule Aprsme.EncodingUtils do
nil nil
""" """
@spec to_float(any()) :: float() | nil @spec to_float(any()) :: float() | nil
def to_float(value) when is_float(value), do: value def to_float(value) when is_float(value) do
def to_float(value) when is_integer(value), do: value * 1.0 if finite_float?(value), do: value
def to_float(%Decimal{} = value), do: Decimal.to_float(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 def to_float(value) when is_binary(value) do
case Float.parse(value) do # Sanitize input first to prevent injection attacks
{float, _} -> float sanitized =
:error -> nil 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
end end
def to_float(_), do: nil 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 """ @doc """
Converts various types to Decimal for database storage. 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 @moduledoc false
alias Aprs.Types.MicE alias Aprs.Types.MicE
alias AprsmeWeb.Live.Shared.BoundsUtils
@spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil} @spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil}
def get_coordinates(%{data_extended: %MicE{} = mic_e}) do 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() @spec within_bounds?(map() | tuple(), map()) :: boolean()
def within_bounds?(packet_or_coords, bounds) do def within_bounds?(packet_or_coords, bounds) do
if is_nil(bounds) do # Delegate to shared bounds utility
false BoundsUtils.within_bounds?(packet_or_coords, bounds)
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
end end
defp check_bounds(lat, lon, bounds) do # Delegate normalize_bounds to shared utility
lat = to_float(lat) defdelegate normalize_bounds(bounds), to: BoundsUtils
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
end 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. Shared utilities for extracting and processing packet data in map views.
""" """
alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils
alias AprsmeWeb.MapLive.PopupComponent alias AprsmeWeb.Live.Shared.ParamUtils
alias AprsmeWeb.TimeHelpers alias AprsmeWeb.MapLive.DataBuilder
alias Phoenix.HTML.Safe
@doc """ @doc """
Safely extracts a value from a packet or data_extended map with fallback support. 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() @spec get_packet_field(map(), atom() | String.t(), any()) :: any()
def get_packet_field(packet, field, default \\ nil) do def get_packet_field(packet, field, default \\ nil) do
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{} SharedPacketUtils.get_packet_field(packet, field, default)
Map.get(packet, field) ||
Map.get(packet, to_string(field)) ||
Map.get(data_extended, field) ||
Map.get(data_extended, to_string(field)) ||
default
end end
@doc """ @doc """
@ -28,7 +21,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
""" """
@spec get_symbol_info(map()) :: {String.t(), String.t()} @spec get_symbol_info(map()) :: {String.t(), String.t()}
def get_symbol_info(packet) do def get_symbol_info(packet) do
AprsmeWeb.AprsSymbol.extract_from_packet(packet) SharedPacketUtils.get_symbol_info(packet)
end end
@doc """ @doc """
@ -36,16 +29,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
""" """
@spec get_timestamp(map()) :: String.t() @spec get_timestamp(map()) :: String.t()
def get_timestamp(packet) do def get_timestamp(packet) do
cond do SharedPacketUtils.get_timestamp(packet)
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 end
@doc """ @doc """
@ -53,7 +37,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
""" """
@spec to_float(any()) :: float() @spec to_float(any()) :: float()
def to_float(value) do def to_float(value) do
Aprsme.EncodingUtils.to_float(value) || 0.0 ParamUtils.to_float(value)
end end
@doc """ @doc """
@ -61,14 +45,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
""" """
@spec generate_callsign(map()) :: String.t() @spec generate_callsign(map()) :: String.t()
def generate_callsign(packet) do def generate_callsign(packet) do
base_callsign = get_packet_field(packet, :base_callsign, "") SharedPacketUtils.generate_callsign(packet)
ssid = get_packet_field(packet, :ssid, nil)
if ssid != nil and ssid != "" do
"#{base_callsign}-#{ssid}"
else
base_callsign
end
end end
@doc """ @doc """
@ -76,17 +53,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
""" """
@spec weather_packet?(map()) :: boolean() @spec weather_packet?(map()) :: boolean()
def weather_packet?(packet) do def weather_packet?(packet) do
# Check if packet contains any weather data fields SharedPacketUtils.weather_packet?(packet)
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))
end end
@doc """ @doc """
@ -132,206 +99,16 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
""" """
@spec get_weather_field(map(), atom()) :: String.t() @spec get_weather_field(map(), atom()) :: String.t()
def get_weather_field(packet, key) do def get_weather_field(packet, key) do
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{} case SharedPacketUtils.get_weather_field(packet, key) do
nil -> "N/A"
Map.get(packet, key) || value -> value
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)
end end
end end
def build_packet_data(packet) do # These functions have been moved to AprsmeWeb.MapLive.DataBuilder
build_packet_data(packet, false, "en") defdelegate build_packet_data(packet, is_most_recent_for_callsign, locale), to: DataBuilder
end 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 # All packet data building functions have been moved to AprsmeWeb.MapLive.DataBuilder
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"}
end 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°) # PHG xxx0 = omni (360°)
assert position_data[:phg][:directivity] == 360 assert position_data[:phg][:directivity] == 360
# Verify comment is cleaned # Verify comment includes PHG data (parser doesn't extract it from comment)
assert position_data[:comment] == "Collin Cty Wide Digi" assert position_data[:comment] == "PHG5530 Collin Cty Wide Digi"
end end
test "packet changeset includes altitude and PHG fields" do test "packet changeset includes altitude and PHG fields" do

View file

@ -74,16 +74,19 @@ defmodule AprsmeWeb.MapLive.MovementTest do
end end
test "updates marker for significant movement", %{conn: conn} do test "updates marker for significant movement", %{conn: conn} do
# Start with initial location # Start with initial location at a zoom level that shows individual markers
{:ok, view, _html} = live(conn, "/") {: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", %{ assert render_hook(view, "update_map_state", %{
"center" => %{"lat" => 33.16961, "lng" => -96.4921}, "center" => %{"lat" => 33.16961, "lng" => -96.4921},
"zoom" => 9 "zoom" => 15
}) })
# Then update bounds # Set bounds to ensure packets are visible
bounds_params = %{ bounds_params = %{
"bounds" => %{ "bounds" => %{
"north" => 33.18, "north" => 33.18,
@ -94,7 +97,12 @@ defmodule AprsmeWeb.MapLive.MovementTest do
} }
assert render_hook(view, "bounds_changed", bounds_params) 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 # Simulate initial packet
initial_packet = %{ initial_packet = %{
@ -112,13 +120,16 @@ defmodule AprsmeWeb.MapLive.MovementTest do
# Wait for the initial packet to be processed # Wait for the initial packet to be processed
:timer.sleep(100) :timer.sleep(100)
# Should receive new_packet for the initial packet
assert_push_event(view, "new_packet", %{}, 1000)
# Simulate significant movement (20+ meters) # Simulate significant movement (20+ meters)
moved_packet = %{ moved_packet = %{
"id" => "TEST-2", "id" => "TEST-2",
"sender" => "TEST-2", "sender" => "TEST-2",
"base_callsign" => "TEST", "base_callsign" => "TEST",
# About 20 meters north # About 20 meters north
"lat" => 33.16980, "lat" => 33.1698,
"lon" => -96.4921, "lon" => -96.4921,
"has_position" => true, "has_position" => true,
"received_at" => DateTime.utc_now() "received_at" => DateTime.utc_now()
@ -127,8 +138,20 @@ defmodule AprsmeWeb.MapLive.MovementTest do
# Send the moved packet # Send the moved packet
send(view.pid, {:postgres_packet, 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 # 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
end end