aprs.me/lib/aprsme_web/live/map_live/data_builder.ex
Graham McInitre 1006fdaefc refactor: remove dead code, duplication, and over-abstractions
- 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
2026-07-21 10:36:49 -05:00

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