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:
commit
9b76a2ca79
23 changed files with 2656 additions and 1298 deletions
18
.kiro/steering/product.md
Normal file
18
.kiro/steering/product.md
Normal 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
115
.kiro/steering/structure.md
Normal 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
100
.kiro/steering/tech.md
Normal 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`
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
24
lib/aprsme_web/live/map_live/bounds_manager.ex
Normal file
24
lib/aprsme_web/live/map_live/bounds_manager.ex
Normal 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
|
||||
570
lib/aprsme_web/live/map_live/data_builder.ex
Normal file
570
lib/aprsme_web/live/map_live/data_builder.ex
Normal 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
|
||||
127
lib/aprsme_web/live/map_live/display_manager.ex
Normal file
127
lib/aprsme_web/live/map_live/display_manager.ex
Normal 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
|
||||
271
lib/aprsme_web/live/map_live/historical_loader.ex
Normal file
271
lib/aprsme_web/live/map_live/historical_loader.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
91
lib/aprsme_web/live/map_live/navigation.ex
Normal file
91
lib/aprsme_web/live/map_live/navigation.ex
Normal 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
|
||||
163
lib/aprsme_web/live/map_live/packet_processor.ex
Normal file
163
lib/aprsme_web/live/map_live/packet_processor.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
67
lib/aprsme_web/live/map_live/rf_path.ex
Normal file
67
lib/aprsme_web/live/map_live/rf_path.ex
Normal 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
|
||||
83
lib/aprsme_web/live/map_live/url_params.ex
Normal file
83
lib/aprsme_web/live/map_live/url_params.ex
Normal 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
|
||||
30
lib/aprsme_web/live/map_live/utils.ex
Normal file
30
lib/aprsme_web/live/map_live/utils.ex
Normal 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
|
||||
155
lib/aprsme_web/live/shared/bounds_utils.ex
Normal file
155
lib/aprsme_web/live/shared/bounds_utils.ex
Normal 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
|
||||
132
lib/aprsme_web/live/shared/coordinate_utils.ex
Normal file
132
lib/aprsme_web/live/shared/coordinate_utils.ex
Normal 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
|
||||
222
lib/aprsme_web/live/shared/packet_utils.ex
Normal file
222
lib/aprsme_web/live/shared/packet_utils.ex
Normal 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
|
||||
181
lib/aprsme_web/live/shared/param_utils.ex
Normal file
181
lib/aprsme_web/live/shared/param_utils.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue