This commit is contained in:
Graham McIntire 2025-06-22 15:25:14 -05:00
parent d0d4c543f2
commit 65ca631115
No known key found for this signature in database
4 changed files with 200 additions and 375 deletions

View file

@ -2,12 +2,10 @@ defmodule AprsWeb.MapLive.CallsignView do
@moduledoc false @moduledoc false
use AprsWeb, :live_view use AprsWeb, :live_view
alias Aprs.EncodingUtils
alias Aprs.Packets alias Aprs.Packets
alias AprsWeb.Endpoint alias AprsWeb.Endpoint
alias AprsWeb.MapLive.MapHelpers alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.MapLive.PacketUtils alias AprsWeb.MapLive.PacketUtils
alias AprsWeb.TimeHelpers
@default_center %{lat: 39.0, lng: -98.0} @default_center %{lat: 39.0, lng: -98.0}
@default_zoom 4 @default_zoom 4
@ -266,61 +264,30 @@ defmodule AprsWeb.MapLive.CallsignView do
def handle_info(:replay_next_packet, socket), do: handle_replay_next_packet(socket) def handle_info(:replay_next_packet, socket), do: handle_replay_next_packet(socket)
def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket) def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket)
def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} = msg, socket) do def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket) do
handle_aprs_packet_broadcast(msg, payload, socket) # Only process packets for the specific callsign being viewed
if PacketUtils.generate_callsign(packet) == socket.assigns.callsign do
handle_info_postgres_packet(packet, socket)
else
{:noreply, socket}
end
end end
def handle_info(_msg, socket), do: {:noreply, socket} def handle_info(_msg, socket), do: {:noreply, socket}
defp handle_aprs_packet_broadcast(_msg, payload, socket) do defp handle_info_postgres_packet(packet, socket) do
sanitized_packet = EncodingUtils.sanitize_packet(payload) key = System.unique_integer([:positive])
sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now()) updated_visible_packets = Map.put(socket.assigns.visible_packets, key, packet)
{lat, lng, _} = MapHelpers.get_coordinates(sanitized_packet) socket = assign(socket, visible_packets: updated_visible_packets)
callsign_key = packet_data = PacketUtils.build_packet_data(packet)
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
if MapHelpers.has_position_data?(sanitized_packet) and socket =
packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and if packet_data,
MapHelpers.within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and do: push_event(socket, "add_markers", %{markers: [packet_data]}),
packet_within_time_threshold?( else: socket
sanitized_packet,
socket.assigns.packet_age_threshold
) do
packet_data = build_packet_data(sanitized_packet)
# Remove any previous marker for this callsign {:noreply, socket}
socket =
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
push_event(socket, "remove_marker", %{id: callsign_key})
else
socket
end
visible_packets = %{callsign_key => sanitized_packet}
last_known_position =
if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position
socket =
socket
|> push_event("new_packet", packet_data)
|> assign(
visible_packets: visible_packets,
last_known_position: last_known_position
)
{:noreply, socket}
else
# Remove marker if it exists and is now out of bounds or expired
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
socket = push_event(socket, "remove_marker", %{id: callsign_key})
visible_packets = %{}
{:noreply, assign(socket, visible_packets: visible_packets)}
else
{:noreply, socket}
end
end
end end
defp handle_replay_next_packet(socket) do defp handle_replay_next_packet(socket) do
@ -338,20 +305,8 @@ defmodule AprsWeb.MapLive.CallsignView do
socket.assigns.replay_index < length(socket.assigns.replay_packets) socket.assigns.replay_index < length(socket.assigns.replay_packets)
end end
defp handle_replay_packet(nil, socket) do
socket =
assign(socket,
replay_active: false,
replay_paused: false,
replay_timer_ref: nil,
replay_index: 0
)
{:noreply, socket}
end
defp handle_replay_packet(packet, socket) do defp handle_replay_packet(packet, socket) do
case build_packet_data(packet) do case PacketUtils.build_packet_data(packet) do
nil -> handle_invalid_replay_packet(socket) nil -> handle_invalid_replay_packet(socket)
packet_data -> handle_valid_replay_packet(packet, packet_data, socket) packet_data -> handle_valid_replay_packet(packet, packet_data, socket)
end end
@ -445,7 +400,7 @@ defmodule AprsWeb.MapLive.CallsignView do
.callsign-header { .callsign-header {
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 10px; left: 60px;
z-index: 1000; z-index: 1000;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
border: 2px solid rgba(0,0,0,0.2); border: 2px solid rgba(0,0,0,0.2);
@ -614,6 +569,11 @@ defmodule AprsWeb.MapLive.CallsignView do
max-width: 300px; max-width: 300px;
} }
/* Hides the empty state dialog when not needed */
.empty-state.hidden {
display: none;
}
.empty-state h3 { .empty-state h3 {
margin: 0 0 8px 0; margin: 0 0 8px 0;
color: #333; color: #333;
@ -638,14 +598,15 @@ defmodule AprsWeb.MapLive.CallsignView do
</div> </div>
</div> </div>
<%= if map_size(@visible_packets) == 0 and not @replay_active do %> <div class={[
<div class="empty-state"> "empty-state",
<h3>Loading Historical Data</h3> if(map_size(@visible_packets) > 0 or @replay_active, do: "hidden")
<p> ]}>
Loading packet history for {@callsign}... <h3>Loading Historical Data</h3>
</p> <p>
</div> Loading packet history for {@callsign}...
<% end %> </p>
</div>
<div <div
id="aprs-map" id="aprs-map"
@ -749,7 +710,7 @@ defmodule AprsWeb.MapLive.CallsignView do
unique_position_packets unique_position_packets
|> Enum.with_index() |> Enum.with_index()
|> Enum.map(fn {packet, index} -> |> Enum.map(fn {packet, index} ->
case build_packet_data(packet) do case PacketUtils.build_packet_data(packet) do
nil -> nil ->
nil nil
@ -794,7 +755,7 @@ defmodule AprsWeb.MapLive.CallsignView do
socket = socket =
if latest_packet && !MapSet.member?(historical_callsigns, latest_packet_id) do if latest_packet && !MapSet.member?(historical_callsigns, latest_packet_id) do
# Push the latest marker as a regular marker (not historical) # Push the latest marker as a regular marker (not historical)
packet_data = build_packet_data(latest_packet) packet_data = PacketUtils.build_packet_data(latest_packet)
if packet_data do if packet_data do
push_event(socket, "add_markers", %{markers: [packet_data]}) push_event(socket, "add_markers", %{markers: [packet_data]})
@ -876,123 +837,6 @@ defmodule AprsWeb.MapLive.CallsignView do
abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001 abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001
end end
defp build_packet_data(packet) do
{lat, lng, _} = MapHelpers.get_coordinates(packet)
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
if lat != nil and lng != nil and callsign != "" and callsign != nil do
build_packet_map(packet, lat, lng, packet.data_extended)
end
end
defp format_popup_timestamp(ts) do
dt =
cond do
is_binary(ts) ->
case DateTime.from_iso8601(ts) do
{:ok, dt, _} -> dt
_ -> DateTime.utc_now()
end
is_integer(ts) ->
DateTime.from_unix!(ts, :millisecond)
match?(%DateTime{}, ts) ->
ts
match?(%NaiveDateTime{}, ts) ->
DateTime.from_naive!(ts, "Etc/UTC")
true ->
DateTime.utc_now()
end
ago = TimeHelpers.time_ago_in_words(dt)
abs_time = Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
{ago, abs_time}
end
defp build_packet_map(packet, lat, lng, data_extended) do
data_extended = data_extended || %{}
callsign = PacketUtils.generate_callsign(packet)
{symbol_table_id, symbol_code} = PacketUtils.get_symbol_info(packet)
timestamp = PacketUtils.get_timestamp(packet)
comment = PacketUtils.get_packet_field(packet, :comment, "")
{ago, abs_time} = format_popup_timestamp(timestamp)
popup = """
<div class=\"aprs-popup\">
<div class=\"aprs-callsign\"><strong><a href=\"/#{callsign}\">#{callsign}</a></strong></div>
<div class=\"aprs-symbol-info\">Symbol: #{symbol_table_id}#{symbol_code}</div>
#{if comment == "", do: "", else: "<div class=\\\"aprs-comment\\\">#{comment}</div>"}
<div class=\"aprs-coords\">#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lng), 4)}</div>
<div class=\"aprs-timestamp\"><div>#{ago}</div><div>#{abs_time}</div></div>
</div>
"""
%{
"id" => callsign,
"callsign" => 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(lng),
"data_type" => to_string(PacketUtils.get_packet_field(packet, :data_type, "unknown")),
"path" => PacketUtils.get_packet_field(packet, :path, ""),
"comment" => comment,
"data_extended" => PacketUtils.convert_tuples_to_strings(data_extended || %{}),
"symbol_table_id" => symbol_table_id,
"symbol_code" => symbol_code,
"symbol_description" => "Symbol: #{symbol_table_id}#{symbol_code}",
"timestamp" => timestamp,
"popup" => popup
}
end
defp packet_matches_callsign?(packet, target_callsign) do
normalized_packet = normalize_packet_callsign(packet)
normalized_target = normalize_target_callsign(target_callsign)
exact_or_base_match?(normalized_packet, normalized_target, packet)
end
defp normalize_packet_callsign(packet) do
base_callsign = packet[:base_callsign] || packet.base_callsign || ""
ssid = packet[:ssid] || packet.ssid
case_result =
case ssid do
nil -> base_callsign
"" -> base_callsign
"0" -> base_callsign
_ -> "#{base_callsign}-#{ssid}"
end
case_result
|> String.upcase()
|> String.trim()
end
defp normalize_target_callsign(target_callsign) do
target_callsign
|> String.upcase()
|> String.trim()
end
defp exact_or_base_match?(normalized_packet, normalized_target, packet) do
cond do
normalized_packet == normalized_target ->
true
not String.contains?(normalized_target, "-") ->
base_callsign = packet[:base_callsign] || packet.base_callsign || ""
String.upcase(String.trim(base_callsign)) == normalized_target
true ->
false
end
end
defp load_callsign_packets(socket, callsign) do defp load_callsign_packets(socket, callsign) do
latest_packet = latest_packet =
%{callsign: callsign} %{callsign: callsign}
@ -1042,7 +886,7 @@ defmodule AprsWeb.MapLive.CallsignView do
defp maybe_push_latest_marker(socket, nil), do: socket defp maybe_push_latest_marker(socket, nil), do: socket
defp maybe_push_latest_marker(socket, packet) do defp maybe_push_latest_marker(socket, packet) do
packet_data = build_packet_data(packet) packet_data = PacketUtils.build_packet_data(packet)
if packet_data, do: push_event(socket, "add_markers", %{markers: [packet_data]}), else: socket if packet_data, do: push_event(socket, "add_markers", %{markers: [packet_data]}), else: socket
end end
end end

View file

@ -4,6 +4,8 @@ defmodule AprsWeb.MapLive.Index do
""" """
use AprsWeb, :live_view use AprsWeb, :live_view
import AprsWeb.TimeHelpers, only: [time_ago_in_words: 1]
alias AprsWeb.Endpoint alias AprsWeb.Endpoint
alias AprsWeb.MapLive.MapHelpers alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.MapLive.PacketUtils alias AprsWeb.MapLive.PacketUtils
@ -195,7 +197,7 @@ defmodule AprsWeb.MapLive.Index do
visible_packets_list = visible_packets_list =
filtered_packets filtered_packets
|> Enum.map(fn {_callsign, packet} -> build_packet_data(packet) end) |> Enum.map(fn {_callsign, packet} -> PacketUtils.build_packet_data(packet) end)
|> Enum.filter(& &1) |> Enum.filter(& &1)
socket = assign(socket, visible_packets: filtered_packets) socket = assign(socket, visible_packets: filtered_packets)
@ -273,7 +275,7 @@ defmodule AprsWeb.MapLive.Index do
{:noreply, socket} {:noreply, socket}
else else
# Navigate to the callsign-specific route # Navigate to the callsign-specific route
{:noreply, push_navigate(socket, to: "/#{trimmed_callsign}")} {:noreply, push_navigate(socket, to: "/map/callsign/#{trimmed_callsign}")}
end end
end end
@ -307,7 +309,7 @@ defmodule AprsWeb.MapLive.Index do
@spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()} @spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()}
defp handle_bounds_update(bounds, socket) do defp handle_bounds_update(bounds, socket) do
# Update the map bounds from the client # Update the map bounds from the client, converting to atom keys
map_bounds = %{ map_bounds = %{
north: bounds["north"], north: bounds["north"],
south: bounds["south"], south: bounds["south"],
@ -324,7 +326,6 @@ defmodule AprsWeb.MapLive.Index do
# Only schedule a bounds update if the bounds have actually changed (with rounding) # Only schedule a bounds update if the bounds have actually changed (with rounding)
if compare_bounds(map_bounds, socket.assigns.map_bounds) do if compare_bounds(map_bounds, socket.assigns.map_bounds) do
{:noreply, socket} {:noreply, socket}
# Cancel any pending bounds update timer
else else
if socket.assigns[:bounds_update_timer] do if socket.assigns[:bounds_update_timer] do
Process.cancel_timer(socket.assigns.bounds_update_timer) Process.cancel_timer(socket.assigns.bounds_update_timer)
@ -495,7 +496,7 @@ defmodule AprsWeb.MapLive.Index do
else: System.unique_integer([:positive]) else: System.unique_integer([:positive])
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet) new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
marker_data = build_packet_data(packet) marker_data = PacketUtils.build_packet_data(packet)
socket = socket =
if marker_data do if marker_data do
@ -903,38 +904,6 @@ defmodule AprsWeb.MapLive.Index do
</p> </p>
</div> </div>
<!-- Navigation Links (Mobile) -->
<div class="lg:hidden pt-4 border-t border-slate-200 space-y-3">
<.link
navigate={~p"/packets"}
class="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<span>View All Packets</span>
</.link>
<.link
navigate={~p"/badpackets"}
class="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span>View Bad Packets</span>
</.link>
</div>
<!-- Deployment Information --> <!-- Deployment Information -->
<div class="pt-4 border-t border-slate-200 space-y-3"> <div class="pt-4 border-t border-slate-200 space-y-3">
<div class="flex items-center space-x-2 text-sm text-slate-600"> <div class="flex items-center space-x-2 text-sm text-slate-600">
@ -1079,7 +1048,7 @@ defmodule AprsWeb.MapLive.Index do
packet_data_list = packet_data_list =
historical_packets historical_packets
|> Enum.group_by(&generate_callsign/1) |> Enum.group_by(&PacketUtils.generate_callsign/1)
|> Enum.flat_map(fn {callsign, packets} -> |> Enum.flat_map(fn {callsign, packets} ->
sorted_packets = sorted_packets =
Enum.sort_by( Enum.sort_by(
@ -1120,7 +1089,7 @@ defmodule AprsWeb.MapLive.Index do
end end
defp build_historical_packet_data(packet, index, callsign) do defp build_historical_packet_data(packet, index, callsign) do
case build_packet_data(packet) do case PacketUtils.build_packet_data(packet) do
nil -> nil ->
nil nil
@ -1208,7 +1177,7 @@ defmodule AprsWeb.MapLive.Index do
packets = packets_module.get_packets_for_replay(packets_params) packets = packets_module.get_packets_for_replay(packets_params)
# Sort packets by received_at timestamp to ensure chronological replay # 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 end
@spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t() @spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t()
@ -1258,146 +1227,6 @@ defmodule AprsWeb.MapLive.Index do
end end
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(<div class="aprs-comment">#{packet_info.comment}</div>)
"""
<div class="aprs-popup">
<div class="aprs-callsign"><strong><a href="/map/callsign/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
#{comment_html}
<div class="aprs-coords">#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lon), 4)}</div>
<div class="aprs-time">#{format_popup_timestamp(packet_info.timestamp)}</div>
</div>
"""
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)
"""
<strong>#{callsign} - Weather Report</strong><br>
<small>#{timestamp_str}</small>
<hr>
Temperature: #{PacketUtils.get_weather_field(packet, :temperature)}°F<br>
Humidity: #{PacketUtils.get_weather_field(packet, :humidity)}%<br>
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<br>
Pressure: #{PacketUtils.get_weather_field(packet, :pressure)} hPa<br>
Rain (1h): #{PacketUtils.get_weather_field(packet, :rain_1h)} in.<br>
Rain (24h): #{PacketUtils.get_weather_field(packet, :rain_24h)} in.<br>
Rain (since midnight): #{PacketUtils.get_weather_field(packet, :rain_since_midnight)} in.<br>
"""
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 # Get location from IP using ip-api.com
@spec get_ip_location(String.t() | nil) :: {float(), float()} | nil @spec get_ip_location(String.t() | nil) :: {float(), float()} | nil
defp get_ip_location(nil), do: 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)) round4.(Map.get(b1, key)) == round4.(Map.get(b2, key))
end) end)
end end
# Use shared helper
defp time_ago_in_words(datetime), do: AprsWeb.TimeHelpers.time_ago_in_words(datetime)
end end

View file

@ -3,6 +3,9 @@ defmodule AprsWeb.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 AprsWeb.MapLive.MapHelpers
alias AprsWeb.TimeHelpers
@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.
Checks both atom and string keys, and provides a default value. 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, key) ||
Map.get(data_extended, to_string(key)) || "N/A" Map.get(data_extended, to_string(key)) || "N/A"
end 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(<div class="aprs-comment">#{packet_info.comment}</div>)
timestamp_dt = TimeHelpers.to_datetime(packet_info.timestamp)
timestamp_html =
if timestamp_dt do
"""
<div class="aprs-timestamp">
<div>#{TimeHelpers.time_ago_in_words(timestamp_dt)}</div>
<div class="text-slate-400">#{Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")}</div>
</div>
"""
else
""
end
"""
<div class="aprs-popup">
<div class="aprs-callsign"><strong><a href="/map/callsign/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
#{comment_html}
<div class="aprs-coords">#{Float.round(to_float(lat), 4)}, #{Float.round(to_float(lon), 4)}</div>
#{timestamp_html}
</div>
"""
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
"""
<div class="aprs-timestamp" style="font-size: 11px; color: #6b7280; padding-top: 0; padding-bottom: 4px;">
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; line-height: 1.4;">#{TimeHelpers.time_ago_in_words(timestamp_dt)}</div>
<div style="font-family: monospace;">#{Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")}</div>
</div>
"""
else
""
end
"""
<strong>#{callsign} - Weather Report</strong>
#{timestamp_html}
<hr style="margin-top: 4px; margin-bottom: 4px;">
Temperature: #{get_weather_field(packet, :temperature)}°F<br>
Humidity: #{get_weather_field(packet, :humidity)}%<br>
Wind: #{get_weather_field(packet, :wind_direction)}° at #{get_weather_field(packet, :wind_speed)} mph, gusts to #{get_weather_field(packet, :wind_gust)} mph<br>
Pressure: #{get_weather_field(packet, :pressure)} hPa<br>
Rain (1h): #{get_weather_field(packet, :rain_1h)} in.<br>
Rain (24h): #{get_weather_field(packet, :rain_24h)} in.<br>
Rain (since midnight): #{get_weather_field(packet, :rain_since_midnight)} in.<br>
"""
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 end

View file

@ -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) when seconds < 63_072_000, do: "1 year"
defp format_time_diff(seconds), do: "#{div(seconds, 31_536_000)} years" 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 end