- Delete dead config keys and test file (typo'd aprsme_is_*, vacuous disable test) - Remove duplicate function clauses and identical function pairs - Rename misleading one_hour_ago variable to one_day_ago - Strip stale Oban comment - Remove TestHelpers time function duplicates - Inline Aprsme.Schema module (only 1 caller) - Deduplicate prod esbuild config - Thin SymbolRenderer delegations, inline TimeUtils wrappers - Remove redundant DeploymentNotifier GenServer polling - Replace random fallback in get_callsign_key with sentinel
711 lines
25 KiB
Elixir
711 lines
25 KiB
Elixir
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.PopupComponent
|
|
alias AprsmeWeb.TimeHelpers
|
|
alias Phoenix.HTML.Safe
|
|
alias Phoenix.LiveView.Socket
|
|
|
|
require Logger
|
|
|
|
@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)
|
|
|
|
callsigns =
|
|
packets_map
|
|
|> Map.values()
|
|
|> Enum.map(&display_name/1)
|
|
|> Enum.uniq()
|
|
|
|
Aprsme.WeatherCache.cache_weather_callsigns(callsigns)
|
|
|
|
packets_map
|
|
|> Enum.map(fn {_callsign, packet} ->
|
|
build_packet_data(packet, is_most_recent, locale)
|
|
end)
|
|
|> Enum.filter(& &1)
|
|
|> deduplicate_by_callsign_group()
|
|
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} = CoordinateUtils.get_coordinates(packet)
|
|
callsign = generate_callsign(packet)
|
|
|
|
# Validate coordinates and callsign before building packet data
|
|
if valid_coordinates?(lat, lon) && callsign != "" do
|
|
packet_data = build_packet_map(packet, to_float(lat), to_float(lon), data_extended, locale)
|
|
Map.put(packet_data, "is_most_recent_for_callsign", is_most_recent_for_callsign)
|
|
else
|
|
# Log invalid data for debugging
|
|
if not valid_coordinates?(lat, lon) do
|
|
Logger.debug("Invalid coordinates in packet #{get_packet_id(packet)}: lat=#{inspect(lat)}, lon=#{inspect(lon)}")
|
|
end
|
|
|
|
nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Build packet data with default locale.
|
|
"""
|
|
@spec build_packet_data(map(), boolean()) :: nil | %{<<_::16, _::_*8>> => any()}
|
|
def build_packet_data(packet, is_most_recent_for_callsign) when is_boolean(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()) :: nil | %{<<_::16, _::_*8>> => any()}
|
|
def build_packet_data(packet) when is_map(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)
|
|
|
|
# Validate coordinates are numeric and within valid ranges
|
|
if valid_coordinates?(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, ">")
|
|
|
|
callsign = display_name(packet)
|
|
label = map_label(packet)
|
|
|
|
# For historical (non-most-recent) packets, use a simple red dot HTML
|
|
# instead of the full APRS symbol — matches what the JS createMarkerIcon
|
|
# would generate anyway, saving the client from icon re-creation
|
|
symbol_html =
|
|
if is_most_recent do
|
|
AprsmeWeb.AprsSymbol.render_marker_html(
|
|
symbol_table_id,
|
|
symbol_code,
|
|
label,
|
|
32
|
|
)
|
|
else
|
|
historical_dot_html(label)
|
|
end
|
|
|
|
raw_comment = get_packet_field(packet, :comment, "")
|
|
clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment)
|
|
|
|
%{
|
|
"id" => if(is_most_recent, do: "current_#{get_packet_id(packet)}", else: "hist_#{get_packet_id(packet)}"),
|
|
"lat" => to_float(lat),
|
|
"lng" => to_float(lon),
|
|
"callsign" => label,
|
|
"callsign_group" => callsign,
|
|
"symbol_table_id" => symbol_table_id,
|
|
"symbol_code" => symbol_code,
|
|
"symbol_html" => symbol_html,
|
|
"comment" => clean_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)
|
|
}
|
|
else
|
|
# Log invalid coordinates for debugging
|
|
Logger.debug("Invalid coordinates in packet #{get_packet_id(packet)}: lat=#{inspect(lat)}, lon=#{inspect(lon)}")
|
|
nil
|
|
end
|
|
end
|
|
|
|
# Validate coordinates are numeric and within valid ranges
|
|
@spec valid_coordinates?(number() | nil, number() | nil) :: boolean()
|
|
defp valid_coordinates?(lat, lon) when is_number(lat) and is_number(lon) do
|
|
# Range checks ensure finite values; infinity would be outside these ranges
|
|
lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180
|
|
end
|
|
|
|
defp valid_coordinates?(_, _), do: false
|
|
|
|
@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
|
|
# Group by display name so objects/items are grouped by their name, not sender
|
|
grouped_packets =
|
|
Enum.group_by(historical_packets, fn packet ->
|
|
display_name(packet)
|
|
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.
|
|
# Output is grouped by callsign with chronological (oldest first) order within
|
|
# each group, so the JS can iterate directly for trail drawing without re-sorting.
|
|
grouped_packets
|
|
|> Enum.flat_map(fn {callsign, packets} ->
|
|
case 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 =
|
|
filter_historical_by_distance(historical, most_recent_lat, most_recent_lon)
|
|
|
|
# Build data for remaining historical packets
|
|
historical_data = build_historical_packet_data(filtered_historical, has_weather)
|
|
|
|
# Sort chronologically (oldest first) within the group for proper trail drawing
|
|
group_packets = Enum.filter([most_recent_data | historical_data], & &1)
|
|
Enum.sort_by(group_packets, & &1["timestamp"])
|
|
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
|
|
packet
|
|
|> build_simple_popup_data()
|
|
|> Map.put(:weather_link, has_weather || SharedPacketUtils.has_weather_data?(packet))
|
|
|> render_popup()
|
|
end
|
|
|
|
defp build_simple_popup_data(packet) do
|
|
is_weather = SharedPacketUtils.has_weather_data?(packet)
|
|
|
|
if is_weather do
|
|
%{
|
|
callsign: map_label(packet),
|
|
comment: nil,
|
|
timestamp_dt: get_packet_received_at(packet),
|
|
cache_buster: System.system_time(:millisecond),
|
|
weather: 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"
|
|
}
|
|
else
|
|
raw_comment = get_packet_field(packet, :comment, "")
|
|
clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment)
|
|
|
|
%{
|
|
callsign: map_label(packet),
|
|
comment: clean_comment,
|
|
timestamp_dt: get_packet_received_at(packet),
|
|
cache_buster: System.system_time(:millisecond),
|
|
weather: false
|
|
}
|
|
end
|
|
end
|
|
|
|
defp render_popup(data) do
|
|
data
|
|
|> PopupComponent.popup()
|
|
|> Safe.to_iodata()
|
|
|> IO.iodata_to_binary()
|
|
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 SharedPacketUtils.has_weather_data?(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(&SharedPacketUtils.has_weather_data?/1)
|
|
|> MapSet.new(fn packet -> String.upcase(display_name(packet)) 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
|
|
raw_comment = get_packet_field(packet, :comment, "")
|
|
clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment)
|
|
|
|
%{
|
|
callsign: map_label(packet),
|
|
callsign_group: display_name(packet),
|
|
symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"),
|
|
symbol_code: get_packet_field(packet, :symbol_code, ">"),
|
|
timestamp: get_timestamp(packet),
|
|
comment: clean_comment,
|
|
safe_data_extended: convert_tuples_to_strings(data_extended),
|
|
is_weather_packet: SharedPacketUtils.has_weather_data?(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(
|
|
%{
|
|
:callsign => binary(),
|
|
:callsign_group => binary(),
|
|
:comment => any(),
|
|
:is_weather_packet => boolean(),
|
|
:safe_data_extended => any(),
|
|
:symbol_code => any(),
|
|
:symbol_table_id => any(),
|
|
:timestamp => binary()
|
|
},
|
|
float(),
|
|
float()
|
|
) :: binary()
|
|
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)
|
|
|
|
popup_data = %{
|
|
callsign: packet_info.callsign,
|
|
comment: packet_info.comment,
|
|
timestamp_dt: timestamp_dt,
|
|
cache_buster: cache_buster,
|
|
weather: false,
|
|
weather_link: weather_link
|
|
}
|
|
|
|
# Optimize string conversion - avoid intermediate iodata step
|
|
popup_data
|
|
|> 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(),
|
|
%{
|
|
:callsign => binary(),
|
|
:callsign_group => binary(),
|
|
:comment => any(),
|
|
:is_weather_packet => boolean(),
|
|
:safe_data_extended => any(),
|
|
:symbol_code => nil | binary(),
|
|
:symbol_table_id => nil | binary(),
|
|
:timestamp => binary()
|
|
},
|
|
float(),
|
|
float(),
|
|
binary()
|
|
) :: %{<<_::16, _::_*8>> => any()}
|
|
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.AprsSymbol.render_marker_html(
|
|
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,
|
|
"callsign_group" => packet_info.callsign_group,
|
|
"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(),
|
|
:base_callsign | :comment | :data_type | :path | :ssid | :symbol_code | :symbol_table_id,
|
|
nil | binary()
|
|
) :: 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(number() | nil) :: 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 display_name(map()) :: String.t()
|
|
defp display_name(packet) do
|
|
SharedPacketUtils.display_name(packet)
|
|
end
|
|
|
|
@spec map_label(map()) :: String.t()
|
|
defp map_label(packet) do
|
|
SharedPacketUtils.map_label(packet)
|
|
end
|
|
|
|
@spec has_weather_packets?(String.t()) :: boolean()
|
|
defp has_weather_packets?(callsign) when is_binary(callsign) do
|
|
case Aprsme.WeatherCache.weather_callsign?(callsign) do
|
|
:unknown ->
|
|
Aprsme.WeatherCache.cache_weather_callsigns([callsign])
|
|
Aprsme.WeatherCache.weather_callsign?(callsign) == true
|
|
|
|
result when is_boolean(result) ->
|
|
result
|
|
end
|
|
end
|
|
|
|
@spec convert_tuples_to_strings(any()) :: any()
|
|
defp convert_tuples_to_strings(map) when is_map(map) do
|
|
# Avoid processing structs entirely
|
|
case map do
|
|
%{__struct__: _} -> map
|
|
_ -> Map.new(map, &convert_tuple_entry/1)
|
|
end
|
|
end
|
|
|
|
defp convert_tuples_to_strings(list) when is_list(list) do
|
|
# Use Stream for memory efficiency on large lists
|
|
list
|
|
|> Stream.map(&convert_tuples_to_strings/1)
|
|
|> Enum.to_list()
|
|
end
|
|
|
|
defp convert_tuples_to_strings(tuple) when is_tuple(tuple) do
|
|
# Optimize tuple string conversion - avoid inspect for common cases
|
|
case tuple_size(tuple) do
|
|
2 ->
|
|
{a, b} = tuple
|
|
"#{a}, #{b}"
|
|
|
|
3 ->
|
|
{a, b, c} = tuple
|
|
"#{a}, #{b}, #{c}"
|
|
|
|
_ ->
|
|
inspect(tuple)
|
|
end
|
|
end
|
|
|
|
defp convert_tuples_to_strings(other), do: other
|
|
|
|
defp convert_tuple_entry({k, v}), do: {k, convert_tuples_to_strings(v)}
|
|
|
|
@typep weather_field ::
|
|
:humidity
|
|
| :pressure
|
|
| :rain_1h
|
|
| :rain_24h
|
|
| :rain_since_midnight
|
|
| :temperature
|
|
| :wind_direction
|
|
| :wind_gust
|
|
| :wind_speed
|
|
|
|
@spec get_weather_field(map(), weather_field()) :: any()
|
|
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(%{received_at: value}), do: value
|
|
defp get_received_at(%{"received_at" => value}), do: value
|
|
defp get_received_at(_packet), do: nil
|
|
|
|
# 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
|
|
@typep unit_formatter :: (number(), String.t() -> {number() | String.t(), String.t()})
|
|
@typep unit_result :: {number() | String.t(), String.t()}
|
|
|
|
@spec convert_temperature(any(), String.t()) :: unit_result()
|
|
defp convert_temperature(value, locale),
|
|
do: convert_unit(value, locale, &AprsmeWeb.WeatherUnits.format_temperature/2, "°F")
|
|
|
|
@spec convert_wind_speed(any(), String.t()) :: unit_result()
|
|
defp convert_wind_speed(value, locale),
|
|
do: convert_unit(value, locale, &AprsmeWeb.WeatherUnits.format_wind_speed/2, "mph")
|
|
|
|
@spec convert_rain(any(), String.t()) :: unit_result()
|
|
defp convert_rain(value, locale), do: convert_unit(value, locale, &AprsmeWeb.WeatherUnits.format_rain/2, "in")
|
|
|
|
@spec convert_unit(any(), String.t(), unit_formatter(), String.t()) :: unit_result()
|
|
defp convert_unit("N/A", _locale, _formatter, default_unit), do: {"N/A", default_unit}
|
|
|
|
defp convert_unit(value, locale, formatter, _default_unit) when is_number(value), do: formatter.(value, locale)
|
|
|
|
defp convert_unit(value, locale, formatter, default_unit) when is_binary(value) do
|
|
case Float.parse(value) do
|
|
{num_value, _} -> formatter.(num_value, locale)
|
|
:error -> {value, default_unit}
|
|
end
|
|
end
|
|
|
|
defp convert_unit(value, _locale, _formatter, default_unit), do: {value, default_unit}
|
|
|
|
defp filter_historical_by_distance(historical, most_recent_lat, most_recent_lon) do
|
|
if most_recent_lat && most_recent_lon do
|
|
Enum.filter(historical, fn packet ->
|
|
packet_far_enough_from_most_recent?(packet, most_recent_lat, most_recent_lon)
|
|
end)
|
|
else
|
|
# If most recent has no coordinates, include all historical
|
|
historical
|
|
end
|
|
end
|
|
|
|
defp packet_far_enough_from_most_recent?(packet, most_recent_lat, most_recent_lon) do
|
|
{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
|
|
|
|
defp historical_dot_html(callsign) do
|
|
escaped = callsign |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
|
|
|
"<div style=\"width: 8px; height: 8px; background-color: #FF6B6B; border: 2px solid #FFFFFF; border-radius: 50%; opacity: 0.8; box-shadow: 0 0 2px rgba(0,0,0,0.3);\" title=\"Historical position for #{escaped}\"></div>"
|
|
end
|
|
|
|
defp build_historical_packet_data(filtered_historical, has_weather) do
|
|
filtered_historical
|
|
|> Enum.map(fn packet -> build_minimal_packet_data(packet, false, has_weather) end)
|
|
|> Enum.filter(& &1)
|
|
end
|
|
|
|
# When multiple senders track the same object, keep only one "current" marker
|
|
# per callsign_group to avoid duplicate labels on the map.
|
|
defp deduplicate_by_callsign_group(marker_list) do
|
|
marker_list
|
|
|> Enum.reduce(%{}, fn marker, acc ->
|
|
group = marker["callsign_group"] || marker["callsign"]
|
|
Map.put(acc, group, marker)
|
|
end)
|
|
|> Map.values()
|
|
end
|
|
end
|