This commit is contained in:
Graham McIntire 2025-06-22 09:48:39 -05:00
parent cad5f4a380
commit 2d13346f92
No known key found for this signature in database
4 changed files with 172 additions and 257 deletions

View file

@ -243,87 +243,81 @@ defmodule AprsWeb.MapLive.CallsignView do
{:noreply, socket} {:noreply, socket}
end end
def handle_info(msg, socket) do def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket) do
case msg do socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
{:delayed_zoom, %{lat: lat, lng: lng}} -> {:noreply, socket}
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) end
{:noreply, socket}
:auto_start_replay -> def handle_info(:auto_start_replay, socket) do
# Auto-start replay if it hasn't been started yet and map is ready if not socket.assigns.replay_started and socket.assigns.map_ready do
if not socket.assigns.replay_started and socket.assigns.map_ready do socket = start_historical_replay(socket)
socket = start_historical_replay(socket) {:noreply, assign(socket, replay_started: true, replay_active: true)}
{:noreply, assign(socket, replay_started: true, replay_active: true)} else
if not socket.assigns.map_ready do
Process.send_after(self(), :auto_start_replay, 1000)
end
{:noreply, socket}
end
end
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(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} = msg, socket) do
handle_aprs_packet_broadcast(msg, payload, socket)
end
def handle_info(_msg, socket), do: {:noreply, socket}
defp handle_aprs_packet_broadcast(_msg, payload, socket) do
sanitized_packet = EncodingUtils.sanitize_packet(payload)
sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now())
{lat, lng, _} = MapHelpers.get_coordinates(sanitized_packet)
callsign_key =
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
if MapHelpers.has_position_data?(sanitized_packet) and
packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and
MapHelpers.within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and
packet_within_time_threshold?(
sanitized_packet,
socket.assigns.packet_age_threshold
) do
packet_data = build_packet_data(sanitized_packet)
# Remove any previous marker for this callsign
socket =
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
push_event(socket, "remove_marker", %{id: callsign_key})
else else
# If map isn't ready yet, try again in a bit socket
if not socket.assigns.map_ready do
Process.send_after(self(), :auto_start_replay, 1000)
end
{:noreply, socket}
end end
:replay_next_packet -> visible_packets = %{callsign_key => sanitized_packet}
handle_replay_next_packet(socket)
:cleanup_old_packets -> last_known_position =
# Clean up packets older than 1 hour from the map display if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position
handle_cleanup_old_packets(socket)
# Stream packets from PubSub only if within bounds socket =
%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} -> socket
sanitized_packet = EncodingUtils.sanitize_packet(payload) |> push_event("new_packet", packet_data)
sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now()) |> assign(
{lat, lng, _} = MapHelpers.get_coordinates(sanitized_packet) visible_packets: visible_packets,
last_known_position: last_known_position
)
callsign_key = {:noreply, socket}
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}" else
# Remove marker if it exists and is now out of bounds or expired
if MapHelpers.has_position_data?(sanitized_packet) and if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and socket = push_event(socket, "remove_marker", %{id: callsign_key})
MapHelpers.within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and visible_packets = %{}
packet_within_time_threshold?( {:noreply, assign(socket, visible_packets: visible_packets)}
sanitized_packet, else
socket.assigns.packet_age_threshold
) do
packet_data = build_packet_data(sanitized_packet)
# Remove any previous marker for this callsign
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
_ ->
# Ignore packets that don't match our callsign or don't have position data
{:noreply, socket} {:noreply, socket}
end
end end
end end
@ -943,7 +937,6 @@ defmodule AprsWeb.MapLive.CallsignView do
end end
defp load_callsign_packets(socket, callsign) do defp load_callsign_packets(socket, callsign) do
# Load only the latest packet for this specific callsign
latest_packet = latest_packet =
%{callsign: callsign} %{callsign: callsign}
|> Packets.get_recent_packets() |> Packets.get_recent_packets()
@ -951,54 +944,11 @@ defmodule AprsWeb.MapLive.CallsignView do
|> Enum.sort_by(& &1.received_at, {:desc, DateTime}) |> Enum.sort_by(& &1.received_at, {:desc, DateTime})
|> List.first() |> List.first()
last_known_position = last_known_position = extract_last_known_position(latest_packet)
case latest_packet do latest_symbol_table_id = extract_latest_symbol_table_id(latest_packet)
nil -> latest_symbol_code = extract_latest_symbol_code(latest_packet)
nil visible_packets = build_visible_packets(latest_packet)
socket = maybe_push_latest_marker(socket, latest_packet)
packet ->
{lat, lng, _} = MapHelpers.get_coordinates(packet)
if lat && lng, do: %{lat: lat, lng: lng}
end
latest_symbol_table_id =
case latest_packet do
%{data_extended: %{symbol_table_id: id}} when is_binary(id) -> id
_ -> "/"
end
latest_symbol_code =
case latest_packet do
%{data_extended: %{symbol_code: code}} when is_binary(code) -> code
_ -> ">"
end
# Build visible_packets map with only the latest packet
visible_packets =
case latest_packet do
nil ->
%{}
packet ->
callsign_key =
"#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}"
%{callsign_key => packet}
end
# Send only the latest marker to the map if it exists
socket =
case latest_packet do
nil ->
socket
packet ->
packet_data = build_packet_data(packet)
if packet_data,
do: push_event(socket, "add_markers", %{markers: [packet_data]}),
else: socket
end
assign(socket, assign(socket,
last_known_position: last_known_position, last_known_position: last_known_position,
@ -1007,4 +957,35 @@ defmodule AprsWeb.MapLive.CallsignView do
latest_symbol_code: latest_symbol_code latest_symbol_code: latest_symbol_code
) )
end end
defp extract_last_known_position(nil), do: nil
defp extract_last_known_position(packet) do
{lat, lng, _} = MapHelpers.get_coordinates(packet)
if lat && lng, do: %{lat: lat, lng: lng}
end
defp extract_latest_symbol_table_id(%{data_extended: %{symbol_table_id: id}}) when is_binary(id), do: id
defp extract_latest_symbol_table_id(_), do: "/"
defp extract_latest_symbol_code(%{data_extended: %{symbol_code: code}}) when is_binary(code), do: code
defp extract_latest_symbol_code(_), do: ">"
defp build_visible_packets(nil), do: %{}
defp build_visible_packets(packet) do
callsign_key =
"#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}"
%{callsign_key => packet}
end
defp maybe_push_latest_marker(socket, nil), do: socket
defp maybe_push_latest_marker(socket, packet) do
packet_data = build_packet_data(packet)
if packet_data, do: push_event(socket, "add_markers", %{markers: [packet_data]}), else: socket
end
end end

View file

@ -1102,35 +1102,7 @@ defmodule AprsWeb.MapLive.Index 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 build_historical_packet_data(packet, index, callsign)
nil ->
nil
packet_data ->
packet_id =
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
is_most_recent = index == 0
packet_data
|> Map.put("id", packet_id)
|> Map.put("is_historical", true)
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|> Map.put("callsign_group", callsign)
|> Map.put(
"timestamp",
case packet.inserted_at do
%NaiveDateTime{} = naive_dt ->
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
%DateTime{} = dt ->
DateTime.to_unix(dt, :millisecond)
_other ->
DateTime.to_unix(DateTime.utc_now(), :millisecond)
end
)
end
end) end)
|> Enum.filter(& &1) |> Enum.filter(& &1)
end) end)
@ -1150,6 +1122,39 @@ defmodule AprsWeb.MapLive.Index do
) )
end end
defp build_historical_packet_data(packet, index, callsign) do
case build_packet_data(packet) do
nil ->
nil
packet_data ->
packet_id =
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
is_most_recent = index == 0
packet_data
|> Map.put("id", packet_id)
|> Map.put("is_historical", true)
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|> Map.put("callsign_group", callsign)
|> Map.put("timestamp", packet_timestamp_ms(packet))
end
end
defp packet_timestamp_ms(packet) do
case packet.inserted_at do
%NaiveDateTime{} = naive_dt ->
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
%DateTime{} = dt ->
DateTime.to_unix(dt, :millisecond)
_other ->
DateTime.to_unix(DateTime.utc_now(), :millisecond)
end
end
# Filter packets to only include those with unique positions (lat/lon changed) # Filter packets to only include those with unique positions (lat/lon changed)
@spec filter_unique_positions([struct()]) :: [struct()] @spec filter_unique_positions([struct()]) :: [struct()]
defp filter_unique_positions(packets) do defp filter_unique_positions(packets) do
@ -1227,78 +1232,10 @@ defmodule AprsWeb.MapLive.Index do
if Enum.empty?(historical_packets) do if Enum.empty?(historical_packets) do
socket socket
else else
process_historical_packets_for_bounds(socket, historical_packets) process_historical_packets(socket, historical_packets)
end end
end end
defp process_historical_packets_for_bounds(socket, historical_packets) do
packet_data_list =
historical_packets
|> Enum.group_by(&generate_callsign/1)
|> Enum.flat_map(fn {callsign, packets} ->
sorted_packets =
Enum.sort_by(
packets,
fn packet ->
case packet.inserted_at do
%NaiveDateTime{} = naive_dt -> DateTime.from_naive!(naive_dt, "Etc/UTC")
%DateTime{} = dt -> dt
_other -> DateTime.utc_now()
end
end,
{:desc, DateTime}
)
unique_position_packets = filter_unique_positions(sorted_packets)
unique_position_packets
|> Enum.with_index()
|> Enum.map(fn {packet, index} ->
case build_packet_data(packet) do
nil ->
nil
packet_data ->
packet_id =
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
is_most_recent = index == 0
packet_data
|> Map.put("id", packet_id)
|> Map.put("is_historical", true)
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|> Map.put("callsign_group", callsign)
|> Map.put(
"timestamp",
case packet.inserted_at do
%NaiveDateTime{} = naive_dt ->
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
%DateTime{} = dt ->
DateTime.to_unix(dt, :millisecond)
_other ->
DateTime.to_unix(DateTime.utc_now(), :millisecond)
end
)
end
end)
|> Enum.filter(& &1)
end)
socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list})
historical_packets_map =
packet_data_list
|> Enum.zip(historical_packets)
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
Map.put(acc, packet_data["id"], packet)
end)
assign(socket, historical_packets: historical_packets_map)
end
@spec within_bounds?(map() | struct(), map()) :: boolean() @spec within_bounds?(map() | struct(), map()) :: boolean()
defp within_bounds?(packet, bounds) do defp within_bounds?(packet, bounds) do
{lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet)
@ -1536,23 +1473,6 @@ defmodule AprsWeb.MapLive.Index do
end) end)
end end
# Rails-style time ago helper # Use shared helper
defp time_ago_in_words(datetime) do defp time_ago_in_words(datetime), do: AprsWeb.TimeHelpers.time_ago_in_words(datetime)
now = DateTime.utc_now()
diff_seconds = DateTime.diff(now, datetime, :second)
cond do
diff_seconds < 60 -> "less than a minute"
diff_seconds < 120 -> "1 minute"
diff_seconds < 3600 -> "#{div(diff_seconds, 60)} minutes"
diff_seconds < 7200 -> "1 hour"
diff_seconds < 86_400 -> "#{div(diff_seconds, 3600)} hours"
diff_seconds < 172_800 -> "1 day"
diff_seconds < 2_592_000 -> "#{div(diff_seconds, 86_400)} days"
diff_seconds < 5_184_000 -> "1 month"
diff_seconds < 31_536_000 -> "#{div(diff_seconds, 2_592_000)} months"
diff_seconds < 63_072_000 -> "1 year"
true -> "#{div(diff_seconds, 31_536_000)} years"
end <> " ago"
end
end end

View file

@ -80,27 +80,14 @@ defmodule AprsWeb.MapLive.MapHelpers do
end end
end end
defp extract_lat_lon(packet_or_coords) do defp extract_lat_lon(%{lat: lat, lon: lon}), do: extract_lat_lon_atom(%{lat: lat, lon: lon})
cond do
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :lat) and
Map.has_key?(packet_or_coords, :lon) ->
extract_lat_lon_atom(packet_or_coords)
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, "lat") and defp extract_lat_lon(%{"lat" => lat, "lon" => lon}), do: extract_lat_lon_string(%{"lat" => lat, "lon" => lon})
Map.has_key?(packet_or_coords, "lon") ->
extract_lat_lon_string(packet_or_coords)
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :latitude) and defp extract_lat_lon(%{latitude: lat, longitude: lon}), do: extract_lat_lon_atom_alt(%{latitude: lat, longitude: lon})
Map.has_key?(packet_or_coords, :longitude) ->
extract_lat_lon_atom_alt(packet_or_coords)
is_tuple(packet_or_coords) and tuple_size(packet_or_coords) == 2 -> defp extract_lat_lon({lat, lon}) when is_number(lat) and is_number(lon), do: {lat, lon}
packet_or_coords defp extract_lat_lon(_), do: {nil, nil}
true ->
{nil, nil}
end
end
defp extract_lat_lon_atom(packet), do: {packet.lat, packet.lon} defp extract_lat_lon_atom(packet), do: {packet.lat, packet.lon}
defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]} defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]}

View file

@ -0,0 +1,27 @@
defmodule AprsWeb.TimeHelpers do
@moduledoc """
Shared helpers for formatting time and dates in the web layer.
"""
@doc """
Returns a human-readable string for how long ago the given DateTime was.
"""
def time_ago_in_words(datetime) do
now = DateTime.utc_now()
diff_seconds = DateTime.diff(now, datetime, :second)
cond do
diff_seconds < 60 -> "less than a minute"
diff_seconds < 120 -> "1 minute"
diff_seconds < 3600 -> "#{div(diff_seconds, 60)} minutes"
diff_seconds < 7200 -> "1 hour"
diff_seconds < 86_400 -> "#{div(diff_seconds, 3600)} hours"
diff_seconds < 172_800 -> "1 day"
diff_seconds < 2_592_000 -> "#{div(diff_seconds, 86_400)} days"
diff_seconds < 5_184_000 -> "1 month"
diff_seconds < 31_536_000 -> "#{div(diff_seconds, 2_592_000)} months"
diff_seconds < 63_072_000 -> "1 year"
true -> "#{div(diff_seconds, 31_536_000)} years"
end <> " ago"
end
end