major refactor for cleanup
This commit is contained in:
parent
b065404803
commit
e6ccb9662b
17 changed files with 2199 additions and 1470 deletions
|
|
@ -101,21 +101,19 @@ defmodule Aprsme.EncodingUtils do
|
||||||
"""
|
"""
|
||||||
@spec to_float(any()) :: float() | nil
|
@spec to_float(any()) :: float() | nil
|
||||||
def to_float(value) when is_float(value) do
|
def to_float(value) when is_float(value) do
|
||||||
if finite_float?(value), do: value, else: nil
|
if finite_float?(value), do: value
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_float(value) when is_integer(value) do
|
def to_float(value) when is_integer(value) do
|
||||||
# Protect against integer overflow when converting to float
|
# Protect against integer overflow when converting to float
|
||||||
if value >= -9.0e15 and value <= 9.0e15 do
|
if value >= -9.0e15 and value <= 9.0e15 do
|
||||||
value * 1.0
|
value * 1.0
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_float(%Decimal{} = value) do
|
def to_float(%Decimal{} = value) do
|
||||||
float = Decimal.to_float(value)
|
float = Decimal.to_float(value)
|
||||||
if finite_float?(float), do: float, else: nil
|
if finite_float?(float), do: float
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_float(value) when is_binary(value) do
|
def to_float(value) when is_binary(value) do
|
||||||
|
|
@ -129,7 +127,7 @@ defmodule Aprsme.EncodingUtils do
|
||||||
|
|
||||||
case Float.parse(sanitized) do
|
case Float.parse(sanitized) do
|
||||||
{float, _} when is_float(float) ->
|
{float, _} when is_float(float) ->
|
||||||
if finite_float?(float), do: float, else: nil
|
if finite_float?(float), do: float
|
||||||
|
|
||||||
:error ->
|
:error ->
|
||||||
nil
|
nil
|
||||||
|
|
|
||||||
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
|
@moduledoc false
|
||||||
|
|
||||||
alias Aprs.Types.MicE
|
alias Aprs.Types.MicE
|
||||||
|
alias AprsmeWeb.Live.Shared.BoundsUtils
|
||||||
|
|
||||||
@spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil}
|
@spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil}
|
||||||
def get_coordinates(%{data_extended: %MicE{} = mic_e}) do
|
def get_coordinates(%{data_extended: %MicE{} = mic_e}) do
|
||||||
|
|
@ -58,83 +59,10 @@ defmodule AprsmeWeb.MapLive.MapHelpers do
|
||||||
|
|
||||||
@spec within_bounds?(map() | tuple(), map()) :: boolean()
|
@spec within_bounds?(map() | tuple(), map()) :: boolean()
|
||||||
def within_bounds?(packet_or_coords, bounds) do
|
def within_bounds?(packet_or_coords, bounds) do
|
||||||
if is_nil(bounds) do
|
# Delegate to shared bounds utility
|
||||||
false
|
BoundsUtils.within_bounds?(packet_or_coords, bounds)
|
||||||
else
|
|
||||||
{lat, lon} = extract_lat_lon(packet_or_coords)
|
|
||||||
|
|
||||||
if is_nil(lat) or is_nil(lon) do
|
|
||||||
false
|
|
||||||
else
|
|
||||||
check_bounds(lat, lon, bounds)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp check_bounds(lat, lon, bounds) do
|
# Delegate normalize_bounds to shared utility
|
||||||
lat = to_float(lat)
|
defdelegate normalize_bounds(bounds), to: BoundsUtils
|
||||||
lon = to_float(lon)
|
|
||||||
south = to_float(bounds.south)
|
|
||||||
north = to_float(bounds.north)
|
|
||||||
west = to_float(bounds.west)
|
|
||||||
east = to_float(bounds.east)
|
|
||||||
|
|
||||||
lat_in_bounds = lat >= south && lat <= north
|
|
||||||
lng_in_bounds = check_longitude_bounds(lon, west, east)
|
|
||||||
|
|
||||||
lat_in_bounds && lng_in_bounds
|
|
||||||
end
|
|
||||||
|
|
||||||
defp check_longitude_bounds(lon, west, east) do
|
|
||||||
if west <= east do
|
|
||||||
lon >= west && lon <= east
|
|
||||||
else
|
|
||||||
lon >= west || lon <= east
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_lat_lon(%{lat: lat, lon: lon}), do: extract_lat_lon_atom(%{lat: lat, lon: lon})
|
|
||||||
|
|
||||||
defp extract_lat_lon(%{"lat" => lat, "lon" => lon}), do: extract_lat_lon_string(%{"lat" => lat, "lon" => lon})
|
|
||||||
|
|
||||||
defp extract_lat_lon(%{latitude: lat, longitude: lon}), do: extract_lat_lon_atom_alt(%{latitude: lat, longitude: lon})
|
|
||||||
|
|
||||||
defp extract_lat_lon({lat, lon}) when is_number(lat) and is_number(lon), do: {lat, lon}
|
|
||||||
defp extract_lat_lon(_), do: {nil, nil}
|
|
||||||
|
|
||||||
defp extract_lat_lon_atom(packet), do: {packet.lat, packet.lon}
|
|
||||||
defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]}
|
|
||||||
defp extract_lat_lon_atom_alt(packet), do: {packet.latitude, packet.longitude}
|
|
||||||
|
|
||||||
defp to_float(value) do
|
|
||||||
Aprsme.EncodingUtils.to_float(value) || 0.0
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Normalizes map bounds from string keys to atom keys and converts values to floats.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> normalize_bounds(%{"north" => "40.5", "south" => "40.0", "east" => "-73.5", "west" => "-74.0"})
|
|
||||||
%{north: 40.5, south: 40.0, east: -73.5, west: -74.0}
|
|
||||||
|
|
||||||
iex> normalize_bounds(%{"north" => 40.5, "south" => 40.0, "east" => -73.5, "west" => -74.0})
|
|
||||||
%{north: 40.5, south: 40.0, east: -73.5, west: -74.0}
|
|
||||||
"""
|
|
||||||
@spec normalize_bounds(map()) :: map()
|
|
||||||
def normalize_bounds(%{"north" => n, "south" => s, "east" => e, "west" => w}) do
|
|
||||||
%{
|
|
||||||
north: to_float(n),
|
|
||||||
south: to_float(s),
|
|
||||||
east: to_float(e),
|
|
||||||
west: to_float(w)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_bounds(%{north: _, south: _, east: _, west: _} = bounds) do
|
|
||||||
# Already normalized with atom keys
|
|
||||||
bounds
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_bounds(_), do: nil
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
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.
|
Shared utilities for extracting and processing packet data in map views.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias AprsmeWeb.MapLive.MapHelpers
|
alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils
|
||||||
alias AprsmeWeb.MapLive.PopupComponent
|
alias AprsmeWeb.Live.Shared.ParamUtils
|
||||||
alias AprsmeWeb.TimeHelpers
|
alias AprsmeWeb.MapLive.DataBuilder
|
||||||
alias Phoenix.HTML.Safe
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Safely extracts a value from a packet or data_extended map with fallback support.
|
Safely extracts a value from a packet or data_extended map with fallback support.
|
||||||
|
|
@ -14,13 +13,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
"""
|
"""
|
||||||
@spec get_packet_field(map(), atom() | String.t(), any()) :: any()
|
@spec get_packet_field(map(), atom() | String.t(), any()) :: any()
|
||||||
def get_packet_field(packet, field, default \\ nil) do
|
def get_packet_field(packet, field, default \\ nil) do
|
||||||
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{}
|
SharedPacketUtils.get_packet_field(packet, field, default)
|
||||||
|
|
||||||
Map.get(packet, field) ||
|
|
||||||
Map.get(packet, to_string(field)) ||
|
|
||||||
Map.get(data_extended, field) ||
|
|
||||||
Map.get(data_extended, to_string(field)) ||
|
|
||||||
default
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -28,7 +21,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
"""
|
"""
|
||||||
@spec get_symbol_info(map()) :: {String.t(), String.t()}
|
@spec get_symbol_info(map()) :: {String.t(), String.t()}
|
||||||
def get_symbol_info(packet) do
|
def get_symbol_info(packet) do
|
||||||
AprsmeWeb.AprsSymbol.extract_from_packet(packet)
|
SharedPacketUtils.get_symbol_info(packet)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -36,16 +29,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
"""
|
"""
|
||||||
@spec get_timestamp(map()) :: String.t()
|
@spec get_timestamp(map()) :: String.t()
|
||||||
def get_timestamp(packet) do
|
def get_timestamp(packet) do
|
||||||
cond do
|
SharedPacketUtils.get_timestamp(packet)
|
||||||
Map.has_key?(packet, :received_at) && packet.received_at ->
|
|
||||||
DateTime.to_iso8601(packet.received_at)
|
|
||||||
|
|
||||||
Map.has_key?(packet, "received_at") && packet["received_at"] ->
|
|
||||||
DateTime.to_iso8601(packet["received_at"])
|
|
||||||
|
|
||||||
true ->
|
|
||||||
""
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -53,7 +37,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
"""
|
"""
|
||||||
@spec to_float(any()) :: float()
|
@spec to_float(any()) :: float()
|
||||||
def to_float(value) do
|
def to_float(value) do
|
||||||
Aprsme.EncodingUtils.to_float(value) || 0.0
|
ParamUtils.to_float(value)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -61,14 +45,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
"""
|
"""
|
||||||
@spec generate_callsign(map()) :: String.t()
|
@spec generate_callsign(map()) :: String.t()
|
||||||
def generate_callsign(packet) do
|
def generate_callsign(packet) do
|
||||||
base_callsign = get_packet_field(packet, :base_callsign, "")
|
SharedPacketUtils.generate_callsign(packet)
|
||||||
ssid = get_packet_field(packet, :ssid, nil)
|
|
||||||
|
|
||||||
if ssid != nil and ssid != "" do
|
|
||||||
"#{base_callsign}-#{ssid}"
|
|
||||||
else
|
|
||||||
base_callsign
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -76,17 +53,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
"""
|
"""
|
||||||
@spec weather_packet?(map()) :: boolean()
|
@spec weather_packet?(map()) :: boolean()
|
||||||
def weather_packet?(packet) do
|
def weather_packet?(packet) do
|
||||||
# Check if packet contains any weather data fields
|
SharedPacketUtils.weather_packet?(packet)
|
||||||
not is_nil(get_packet_field(packet, :temperature, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :humidity, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :pressure, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :wind_speed, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :wind_direction, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :rain_1h, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :rain_24h, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :rain_since_midnight, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :snow, nil)) or
|
|
||||||
not is_nil(get_packet_field(packet, :luminosity, nil))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -132,206 +99,16 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
"""
|
"""
|
||||||
@spec get_weather_field(map(), atom()) :: String.t()
|
@spec get_weather_field(map(), atom()) :: String.t()
|
||||||
def get_weather_field(packet, key) do
|
def get_weather_field(packet, key) do
|
||||||
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{}
|
case SharedPacketUtils.get_weather_field(packet, key) do
|
||||||
|
nil -> "N/A"
|
||||||
Map.get(packet, key) ||
|
value -> value
|
||||||
Map.get(packet, to_string(key)) ||
|
|
||||||
Map.get(data_extended, key) ||
|
|
||||||
Map.get(data_extended, to_string(key)) || "N/A"
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_packet_data(packet, is_most_recent_for_callsign, locale \\ "en")
|
|
||||||
when is_boolean(is_most_recent_for_callsign) do
|
|
||||||
{lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
|
|
||||||
callsign = generate_callsign(packet)
|
|
||||||
|
|
||||||
if lat && lon && callsign != "" && callsign != nil do
|
|
||||||
packet_data = build_packet_map(packet, lat, lon, data_extended, locale)
|
|
||||||
Map.put(packet_data, "is_most_recent_for_callsign", is_most_recent_for_callsign)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_packet_data(packet) do
|
# These functions have been moved to AprsmeWeb.MapLive.DataBuilder
|
||||||
build_packet_data(packet, false, "en")
|
defdelegate build_packet_data(packet, is_most_recent_for_callsign, locale), to: DataBuilder
|
||||||
end
|
defdelegate build_packet_data(packet, is_most_recent_for_callsign), to: DataBuilder
|
||||||
|
defdelegate build_packet_data(packet), to: DataBuilder
|
||||||
|
|
||||||
defp build_packet_map(packet, lat, lon, data_extended, locale) do
|
# All packet data building functions have been moved to AprsmeWeb.MapLive.DataBuilder
|
||||||
data_extended = data_extended || %{}
|
|
||||||
packet_info = extract_packet_info(packet, data_extended)
|
|
||||||
popup = build_popup_content(packet, packet_info, lat, lon, locale)
|
|
||||||
|
|
||||||
build_packet_result(packet, packet_info, lat, lon, popup)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_packet_info(packet, data_extended) do
|
|
||||||
%{
|
|
||||||
callsign: get_packet_field(packet, :sender, ""),
|
|
||||||
symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"),
|
|
||||||
symbol_code: get_packet_field(packet, :symbol_code, ">"),
|
|
||||||
timestamp: get_timestamp(packet),
|
|
||||||
comment: get_packet_field(packet, :comment, ""),
|
|
||||||
safe_data_extended: convert_tuples_to_strings(data_extended),
|
|
||||||
is_weather_packet: weather_packet?(packet)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_popup_content(packet, packet_info, lat, lon, locale) do
|
|
||||||
if packet_info.is_weather_packet do
|
|
||||||
build_weather_popup_html(packet, packet_info.callsign, locale)
|
|
||||||
else
|
|
||||||
build_standard_popup_html(packet_info, lat, lon)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_standard_popup_html(packet_info, _lat, _lon) do
|
|
||||||
timestamp_dt = TimeHelpers.to_datetime(packet_info.timestamp)
|
|
||||||
cache_buster = System.system_time(:millisecond)
|
|
||||||
weather_link = has_weather_packets?(packet_info.callsign)
|
|
||||||
|
|
||||||
%{
|
|
||||||
callsign: packet_info.callsign,
|
|
||||||
comment: packet_info.comment,
|
|
||||||
timestamp_dt: timestamp_dt,
|
|
||||||
cache_buster: cache_buster,
|
|
||||||
weather: false,
|
|
||||||
weather_link: weather_link
|
|
||||||
}
|
|
||||||
|> PopupComponent.popup()
|
|
||||||
|> Safe.to_iodata()
|
|
||||||
|> IO.iodata_to_binary()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_weather_popup_html(packet, sender, locale) do
|
|
||||||
received_at = get_received_at(packet)
|
|
||||||
timestamp_dt = TimeHelpers.to_datetime(received_at)
|
|
||||||
cache_buster = System.system_time(:millisecond)
|
|
||||||
|
|
||||||
# Get weather data and convert units based on locale
|
|
||||||
temperature_raw = get_weather_field(packet, :temperature)
|
|
||||||
wind_speed_raw = get_weather_field(packet, :wind_speed)
|
|
||||||
wind_gust_raw = get_weather_field(packet, :wind_gust)
|
|
||||||
rain_1h_raw = get_weather_field(packet, :rain_1h)
|
|
||||||
rain_24h_raw = get_weather_field(packet, :rain_24h)
|
|
||||||
rain_since_midnight_raw = get_weather_field(packet, :rain_since_midnight)
|
|
||||||
|
|
||||||
# Convert units if the values are numbers
|
|
||||||
{temperature, temp_unit} = convert_temperature(temperature_raw, locale)
|
|
||||||
{wind_speed, wind_unit} = convert_wind_speed(wind_speed_raw, locale)
|
|
||||||
{wind_gust, gust_unit} = convert_wind_speed(wind_gust_raw, locale)
|
|
||||||
{rain_1h, rain_1h_unit} = convert_rain(rain_1h_raw, locale)
|
|
||||||
{rain_24h, rain_24h_unit} = convert_rain(rain_24h_raw, locale)
|
|
||||||
{rain_since_midnight, rain_since_midnight_unit} = convert_rain(rain_since_midnight_raw, locale)
|
|
||||||
|
|
||||||
%{
|
|
||||||
callsign: sender,
|
|
||||||
comment: nil,
|
|
||||||
timestamp_dt: timestamp_dt,
|
|
||||||
cache_buster: cache_buster,
|
|
||||||
weather: true,
|
|
||||||
weather_link: true,
|
|
||||||
temperature: temperature,
|
|
||||||
temp_unit: temp_unit,
|
|
||||||
humidity: get_weather_field(packet, :humidity),
|
|
||||||
wind_direction: get_weather_field(packet, :wind_direction),
|
|
||||||
wind_speed: wind_speed,
|
|
||||||
wind_unit: wind_unit,
|
|
||||||
wind_gust: wind_gust,
|
|
||||||
gust_unit: gust_unit,
|
|
||||||
pressure: get_weather_field(packet, :pressure),
|
|
||||||
rain_1h: rain_1h,
|
|
||||||
rain_24h: rain_24h,
|
|
||||||
rain_since_midnight: rain_since_midnight,
|
|
||||||
rain_1h_unit: rain_1h_unit,
|
|
||||||
rain_24h_unit: rain_24h_unit,
|
|
||||||
rain_since_midnight_unit: rain_since_midnight_unit
|
|
||||||
}
|
|
||||||
|> PopupComponent.popup()
|
|
||||||
|> Safe.to_iodata()
|
|
||||||
|> IO.iodata_to_binary()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_packet_result(packet, packet_info, lat, lon, popup) do
|
|
||||||
# Generate unique ID for live packets to prevent conflicts
|
|
||||||
packet_id =
|
|
||||||
if Map.has_key?(packet, :id) do
|
|
||||||
"live_#{packet.id}_#{System.unique_integer([:positive])}"
|
|
||||||
else
|
|
||||||
"live_#{packet_info.callsign}_#{System.unique_integer([:positive])}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generate symbol HTML using the server-side renderer
|
|
||||||
symbol_html =
|
|
||||||
AprsmeWeb.SymbolRenderer.render_marker_symbol(
|
|
||||||
packet_info.symbol_table_id,
|
|
||||||
packet_info.symbol_code,
|
|
||||||
packet_info.callsign,
|
|
||||||
32
|
|
||||||
)
|
|
||||||
|
|
||||||
path_value = get_packet_field(packet, :path, "")
|
|
||||||
|
|
||||||
%{
|
|
||||||
"id" => packet_id,
|
|
||||||
"callsign" => packet_info.callsign,
|
|
||||||
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
|
|
||||||
"ssid" => get_packet_field(packet, :ssid, nil),
|
|
||||||
"lat" => to_float(lat),
|
|
||||||
"lng" => to_float(lon),
|
|
||||||
"data_type" => to_string(get_packet_field(packet, :data_type, "unknown")),
|
|
||||||
"path" => path_value,
|
|
||||||
"comment" => packet_info.comment,
|
|
||||||
"data_extended" => packet_info.safe_data_extended || %{},
|
|
||||||
"symbol_table_id" => packet_info.symbol_table_id,
|
|
||||||
"symbol_code" => packet_info.symbol_code,
|
|
||||||
"symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}",
|
|
||||||
"timestamp" => packet_info.timestamp,
|
|
||||||
"popup" => popup,
|
|
||||||
"symbol_html" => symbol_html
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_received_at(packet) do
|
|
||||||
cond do
|
|
||||||
Map.has_key?(packet, :received_at) -> packet.received_at
|
|
||||||
Map.has_key?(packet, "received_at") -> packet["received_at"]
|
|
||||||
true -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Weather unit conversion helpers
|
|
||||||
defp convert_temperature(value, locale) when is_binary(value) and value != "N/A" do
|
|
||||||
case Float.parse(value) do
|
|
||||||
{num_value, _} ->
|
|
||||||
AprsmeWeb.WeatherUnits.format_temperature(num_value, locale)
|
|
||||||
|
|
||||||
:error ->
|
|
||||||
{value, "°F"}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp convert_temperature(value, _locale), do: {value, "°F"}
|
|
||||||
|
|
||||||
defp convert_wind_speed(value, locale) when is_binary(value) and value != "N/A" do
|
|
||||||
case Float.parse(value) do
|
|
||||||
{num_value, _} ->
|
|
||||||
AprsmeWeb.WeatherUnits.format_wind_speed(num_value, locale)
|
|
||||||
|
|
||||||
:error ->
|
|
||||||
{value, "mph"}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp convert_wind_speed(value, _locale), do: {value, "mph"}
|
|
||||||
|
|
||||||
defp convert_rain(value, locale) when is_binary(value) and value != "N/A" do
|
|
||||||
case Float.parse(value) do
|
|
||||||
{num_value, _} ->
|
|
||||||
AprsmeWeb.WeatherUnits.format_rain(num_value, locale)
|
|
||||||
|
|
||||||
:error ->
|
|
||||||
{value, "in"}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp convert_rain(value, _locale), do: {value, "in"}
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
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
|
||||||
Loading…
Add table
Reference in a new issue