aprs.me/lib/aprsme_web/live/map_live/data_builder.ex
Graham McIntire 48bc27e43c
fix: Resolve majority of dialyzer type errors
- Fix leader election guard test for :undefined pid
- Fix cpu_sup.util() pattern matching to handle numeric return
- Fix encoding_utils to always return explicit nil when needed
- Remove unused finite_float? function
- Fix signal handler to match :ok return from :os.set_signal/2
- Remove redundant catch-all pattern in database metrics
- Fix Gridsquare pattern matching in info_live template
- Remove unnecessary nil check for calculate_course result
- Fix get_packet_received_at to not check for nil (always returns DateTime)
- Remove redundant catch-all pattern in weather format_weather_value
- Fix query builder to use from(p in Packet) instead of bare Packet atom

Reduced dialyzer errors from 49 to 6. Remaining warnings are mostly
false positives from template compilation and overloaded function specs.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 13:30:21 -05:00

594 lines
20 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.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)
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)
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(), 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
Aprsme.Packets.has_weather_packets?(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
# 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)}
@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