diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index 08412cf..f188249 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -243,87 +243,81 @@ defmodule AprsWeb.MapLive.CallsignView do {:noreply, socket} end - def handle_info(msg, socket) do - case msg do - {:delayed_zoom, %{lat: lat, lng: lng}} -> - socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) - {:noreply, socket} + def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket) do + socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) + {:noreply, socket} + end - :auto_start_replay -> - # 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 - socket = start_historical_replay(socket) - {:noreply, assign(socket, replay_started: true, replay_active: true)} + def handle_info(:auto_start_replay, socket) do + if not socket.assigns.replay_started and socket.assigns.map_ready do + socket = start_historical_replay(socket) + {: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 - # If map isn't ready yet, try again in a bit - if not socket.assigns.map_ready do - Process.send_after(self(), :auto_start_replay, 1000) - end - - {:noreply, socket} + socket end - :replay_next_packet -> - handle_replay_next_packet(socket) + visible_packets = %{callsign_key => sanitized_packet} - :cleanup_old_packets -> - # Clean up packets older than 1 hour from the map display - handle_cleanup_old_packets(socket) + last_known_position = + if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position - # Stream packets from PubSub only if within bounds - %Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} -> - 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) + socket = + socket + |> push_event("new_packet", packet_data) + |> assign( + visible_packets: visible_packets, + last_known_position: last_known_position + ) - 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 - 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} + 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 @@ -943,7 +937,6 @@ defmodule AprsWeb.MapLive.CallsignView do end defp load_callsign_packets(socket, callsign) do - # Load only the latest packet for this specific callsign latest_packet = %{callsign: callsign} |> Packets.get_recent_packets() @@ -951,54 +944,11 @@ defmodule AprsWeb.MapLive.CallsignView do |> Enum.sort_by(& &1.received_at, {:desc, DateTime}) |> List.first() - last_known_position = - case latest_packet do - nil -> - nil - - 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 + last_known_position = extract_last_known_position(latest_packet) + latest_symbol_table_id = extract_latest_symbol_table_id(latest_packet) + latest_symbol_code = extract_latest_symbol_code(latest_packet) + visible_packets = build_visible_packets(latest_packet) + socket = maybe_push_latest_marker(socket, latest_packet) assign(socket, last_known_position: last_known_position, @@ -1007,4 +957,35 @@ defmodule AprsWeb.MapLive.CallsignView do latest_symbol_code: latest_symbol_code ) 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 diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index a1f6df2..58a83f0 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -1102,35 +1102,7 @@ defmodule AprsWeb.MapLive.Index do 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 + build_historical_packet_data(packet, index, callsign) end) |> Enum.filter(& &1) end) @@ -1150,6 +1122,39 @@ defmodule AprsWeb.MapLive.Index do ) 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) @spec filter_unique_positions([struct()]) :: [struct()] defp filter_unique_positions(packets) do @@ -1227,78 +1232,10 @@ defmodule AprsWeb.MapLive.Index do if Enum.empty?(historical_packets) do socket else - process_historical_packets_for_bounds(socket, historical_packets) + process_historical_packets(socket, historical_packets) 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() defp within_bounds?(packet, bounds) do {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) @@ -1536,23 +1473,6 @@ defmodule AprsWeb.MapLive.Index do end) end - # Rails-style time ago helper - defp 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 + # 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/map_helpers.ex b/lib/aprs_web/live/map_live/map_helpers.ex index d90e42d..2492cc6 100644 --- a/lib/aprs_web/live/map_live/map_helpers.ex +++ b/lib/aprs_web/live/map_live/map_helpers.ex @@ -80,27 +80,14 @@ defmodule AprsWeb.MapLive.MapHelpers do end end - defp extract_lat_lon(packet_or_coords) do - 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) + defp extract_lat_lon(%{lat: lat, lon: lon}), do: extract_lat_lon_atom(%{lat: lat, lon: lon}) - is_map(packet_or_coords) and Map.has_key?(packet_or_coords, "lat") and - Map.has_key?(packet_or_coords, "lon") -> - extract_lat_lon_string(packet_or_coords) + defp extract_lat_lon(%{"lat" => lat, "lon" => lon}), do: extract_lat_lon_string(%{"lat" => lat, "lon" => lon}) - is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :latitude) and - Map.has_key?(packet_or_coords, :longitude) -> - extract_lat_lon_atom_alt(packet_or_coords) + defp extract_lat_lon(%{latitude: lat, longitude: lon}), do: extract_lat_lon_atom_alt(%{latitude: lat, longitude: lon}) - is_tuple(packet_or_coords) and tuple_size(packet_or_coords) == 2 -> - packet_or_coords - - true -> - {nil, nil} - end - end + defp extract_lat_lon({lat, lon}) when is_number(lat) and is_number(lon), do: {lat, lon} + defp extract_lat_lon(_), do: {nil, nil} defp extract_lat_lon_atom(packet), do: {packet.lat, packet.lon} defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]} diff --git a/lib/aprs_web/time_helpers.ex b/lib/aprs_web/time_helpers.ex new file mode 100644 index 0000000..5eba144 --- /dev/null +++ b/lib/aprs_web/time_helpers.ex @@ -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