@@ -1079,7 +1048,7 @@ defmodule AprsWeb.MapLive.Index do
packet_data_list =
historical_packets
- |> Enum.group_by(&generate_callsign/1)
+ |> Enum.group_by(&PacketUtils.generate_callsign/1)
|> Enum.flat_map(fn {callsign, packets} ->
sorted_packets =
Enum.sort_by(
@@ -1120,7 +1089,7 @@ defmodule AprsWeb.MapLive.Index do
end
defp build_historical_packet_data(packet, index, callsign) do
- case build_packet_data(packet) do
+ case PacketUtils.build_packet_data(packet) do
nil ->
nil
@@ -1208,7 +1177,7 @@ defmodule AprsWeb.MapLive.Index do
packets = packets_module.get_packets_for_replay(packets_params)
# Sort packets by received_at timestamp to ensure chronological replay
- Enum.sort_by(packets, fn packet -> packet.received_at end)
+ Enum.sort_by(packets, & &1.received_at)
end
@spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t()
@@ -1258,146 +1227,6 @@ defmodule AprsWeb.MapLive.Index do
end
end
- @spec build_packet_data(map() | struct()) :: map() | nil
- defp build_packet_data(packet) do
- {lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
- callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
-
- # Only include packets with valid position data and a non-empty callsign
- if lat && lon && callsign != "" && callsign != nil do
- build_packet_map(packet, lat, lon, data_extended)
- end
- end
-
- @spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map()
- defp build_packet_map(packet, lat, lon, data_extended) do
- data_extended = data_extended || %{}
- packet_info = extract_packet_info(packet, data_extended)
- popup = build_popup_content(packet, packet_info, lat, lon)
-
- build_packet_result(packet, packet_info, lat, lon, popup)
- end
-
- defp extract_packet_info(packet, data_extended) do
- %{
- callsign: PacketUtils.generate_callsign(packet),
- symbol_table_id: PacketUtils.get_packet_field(packet, :symbol_table_id, "/"),
- symbol_code: PacketUtils.get_packet_field(packet, :symbol_code, ">"),
- timestamp: PacketUtils.get_timestamp(packet),
- comment: PacketUtils.get_packet_field(packet, :comment, ""),
- safe_data_extended: PacketUtils.convert_tuples_to_strings(data_extended),
- is_weather_packet: PacketUtils.weather_packet?(packet)
- }
- end
-
- defp build_popup_content(packet, packet_info, lat, lon) do
- if packet_info.is_weather_packet do
- build_weather_popup_html(packet, packet_info.callsign)
- else
- build_standard_popup_html(packet_info, lat, lon)
- end
- end
-
- defp build_standard_popup_html(packet_info, lat, lon) do
- comment_html =
- if packet_info.comment == "",
- do: "",
- else: ~s()
-
- """
-
- """
- end
-
- defp build_packet_result(packet, packet_info, lat, lon, popup) do
- %{
- "id" => packet_info.callsign,
- "callsign" => packet_info.callsign,
- "base_callsign" => PacketUtils.get_packet_field(packet, :base_callsign, ""),
- "ssid" => PacketUtils.get_packet_field(packet, :ssid, 0),
- "lat" => PacketUtils.to_float(lat),
- "lng" => PacketUtils.to_float(lon),
- "data_type" => to_string(PacketUtils.get_packet_field(packet, :data_type, "unknown")),
- "path" => PacketUtils.get_packet_field(packet, :path, ""),
- "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
- }
- end
-
- defp build_weather_popup_html(packet, callsign) do
- received_at = get_received_at(packet)
- timestamp_str = format_popup_timestamp(received_at)
-
- """
-
#{callsign} - Weather Report
-
#{timestamp_str}
-
- Temperature: #{PacketUtils.get_weather_field(packet, :temperature)}°F
- Humidity: #{PacketUtils.get_weather_field(packet, :humidity)}%
- Wind: #{PacketUtils.get_weather_field(packet, :wind_direction)}° at #{PacketUtils.get_weather_field(packet, :wind_speed)} mph, gusts to #{PacketUtils.get_weather_field(packet, :wind_gust)} mph
- Pressure: #{PacketUtils.get_weather_field(packet, :pressure)} hPa
- Rain (1h): #{PacketUtils.get_weather_field(packet, :rain_1h)} in.
- Rain (24h): #{PacketUtils.get_weather_field(packet, :rain_24h)} in.
- Rain (since midnight): #{PacketUtils.get_weather_field(packet, :rain_since_midnight)} in.
- """
- 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
-
- defp format_popup_timestamp(ts) do
- cond do
- is_binary(ts) ->
- case DateTime.from_iso8601(ts) do
- {:ok, dt, _} -> Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC")
- _ -> ts
- end
-
- is_integer(ts) ->
- ts
- |> DateTime.from_unix!(:millisecond)
- |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
-
- match?(%DateTime{}, ts) ->
- Calendar.strftime(ts, "%Y-%m-%d %H:%M:%S UTC")
-
- match?(%NaiveDateTime{}, ts) ->
- ts
- |> DateTime.from_naive!("Etc/UTC")
- |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
-
- true ->
- to_string(ts)
- end
- end
-
- @spec generate_callsign(map() | struct()) :: String.t()
- defp generate_callsign(packet) do
- base_callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
- ssid = Map.get(packet, :ssid, Map.get(packet, "ssid", 0))
-
- if ssid != 0 and ssid != "" and ssid != nil do
- "#{base_callsign}-#{ssid}"
- else
- base_callsign
- end
- end
-
# Get location from IP using ip-api.com
@spec get_ip_location(String.t() | nil) :: {float(), float()} | nil
defp get_ip_location(nil), do: nil
@@ -1469,7 +1298,4 @@ defmodule AprsWeb.MapLive.Index do
round4.(Map.get(b1, key)) == round4.(Map.get(b2, key))
end)
end
-
- # Use shared helper
- defp time_ago_in_words(datetime), do: AprsWeb.TimeHelpers.time_ago_in_words(datetime)
end
diff --git a/lib/aprs_web/live/map_live/packet_utils.ex b/lib/aprs_web/live/map_live/packet_utils.ex
index bee56e7..5cff344 100644
--- a/lib/aprs_web/live/map_live/packet_utils.ex
+++ b/lib/aprs_web/live/map_live/packet_utils.ex
@@ -3,6 +3,9 @@ defmodule AprsWeb.MapLive.PacketUtils do
Shared utilities for extracting and processing packet data in map views.
"""
+ alias AprsWeb.MapLive.MapHelpers
+ alias AprsWeb.TimeHelpers
+
@doc """
Safely extracts a value from a packet or data_extended map with fallback support.
Checks both atom and string keys, and provides a default value.
@@ -134,4 +137,129 @@ defmodule AprsWeb.MapLive.PacketUtils do
Map.get(data_extended, key) ||
Map.get(data_extended, to_string(key)) || "N/A"
end
+
+ def build_packet_data(packet) do
+ {lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
+ callsign = generate_callsign(packet)
+
+ if lat && lon && callsign != "" && callsign != nil do
+ build_packet_map(packet, lat, lon, data_extended)
+ end
+ end
+
+ defp build_packet_map(packet, lat, lon, data_extended) do
+ data_extended = data_extended || %{}
+ packet_info = extract_packet_info(packet, data_extended)
+ popup = build_popup_content(packet, packet_info, lat, lon)
+
+ build_packet_result(packet, packet_info, lat, lon, popup)
+ end
+
+ defp extract_packet_info(packet, data_extended) do
+ %{
+ callsign: generate_callsign(packet),
+ 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) do
+ if packet_info.is_weather_packet do
+ build_weather_popup_html(packet, packet_info.callsign)
+ else
+ build_standard_popup_html(packet_info, lat, lon)
+ end
+ end
+
+ defp build_standard_popup_html(packet_info, lat, lon) do
+ comment_html =
+ if packet_info.comment == "",
+ do: "",
+ else: ~s()
+
+ timestamp_dt = TimeHelpers.to_datetime(packet_info.timestamp)
+
+ timestamp_html =
+ if timestamp_dt do
+ """
+
+
#{TimeHelpers.time_ago_in_words(timestamp_dt)}
+
#{Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")}
+
+ """
+ else
+ ""
+ end
+
+ """
+
+ """
+ end
+
+ defp build_weather_popup_html(packet, callsign) do
+ received_at = get_received_at(packet)
+ timestamp_dt = TimeHelpers.to_datetime(received_at)
+
+ timestamp_html =
+ if timestamp_dt do
+ """
+
+
#{TimeHelpers.time_ago_in_words(timestamp_dt)}
+
#{Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")}
+
+ """
+ else
+ ""
+ end
+
+ """
+
#{callsign} - Weather Report
+ #{timestamp_html}
+
+ Temperature: #{get_weather_field(packet, :temperature)}°F
+ Humidity: #{get_weather_field(packet, :humidity)}%
+ Wind: #{get_weather_field(packet, :wind_direction)}° at #{get_weather_field(packet, :wind_speed)} mph, gusts to #{get_weather_field(packet, :wind_gust)} mph
+ Pressure: #{get_weather_field(packet, :pressure)} hPa
+ Rain (1h): #{get_weather_field(packet, :rain_1h)} in.
+ Rain (24h): #{get_weather_field(packet, :rain_24h)} in.
+ Rain (since midnight): #{get_weather_field(packet, :rain_since_midnight)} in.
+ """
+ end
+
+ defp build_packet_result(packet, packet_info, lat, lon, popup) do
+ %{
+ "id" => packet_info.callsign,
+ "callsign" => packet_info.callsign,
+ "base_callsign" => get_packet_field(packet, :base_callsign, ""),
+ "ssid" => get_packet_field(packet, :ssid, 0),
+ "lat" => to_float(lat),
+ "lng" => to_float(lon),
+ "data_type" => to_string(get_packet_field(packet, :data_type, "unknown")),
+ "path" => get_packet_field(packet, :path, ""),
+ "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
+ }
+ 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
end
diff --git a/lib/aprs_web/time_helpers.ex b/lib/aprs_web/time_helpers.ex
index a84df51..d4d7dd8 100644
--- a/lib/aprs_web/time_helpers.ex
+++ b/lib/aprs_web/time_helpers.ex
@@ -26,4 +26,31 @@ defmodule AprsWeb.TimeHelpers do
defp format_time_diff(seconds) when seconds < 63_072_000, do: "1 year"
defp format_time_diff(seconds), do: "#{div(seconds, 31_536_000)} years"
+
+ @doc """
+ Converts various timestamp formats into a standard DateTime object.
+ Handles ISO 8601 strings, Unix timestamps in milliseconds, and existing
+ DateTime or NaiveDateTime structs.
+ """
+ def to_datetime(ts) do
+ cond do
+ is_binary(ts) ->
+ case DateTime.from_iso8601(ts) do
+ {:ok, dt, _} -> dt
+ _ -> nil
+ end
+
+ is_integer(ts) ->
+ DateTime.from_unix!(ts, :millisecond)
+
+ match?(%DateTime{}, ts) ->
+ ts
+
+ match?(%NaiveDateTime{}, ts) ->
+ DateTime.from_naive!(ts, "Etc/UTC")
+
+ true ->
+ nil
+ end
+ end
end