diff --git a/lib/aprs_web/live/info_live/show.ex b/lib/aprs_web/live/info_live/show.ex new file mode 100644 index 0000000..c992526 --- /dev/null +++ b/lib/aprs_web/live/info_live/show.ex @@ -0,0 +1,108 @@ +defmodule AprsWeb.InfoLive.Show do + @moduledoc false + use AprsWeb, :live_view + + alias Aprs.Packets + alias AprsWeb.MapLive.PacketUtils + + @neighbor_radius_km 10 + @neighbor_limit 10 + + @impl true + def mount(%{"callsign" => callsign}, _session, socket) do + normalized_callsign = String.upcase(String.trim(callsign)) + packet = get_latest_packet(normalized_callsign) + neighbors = get_neighbors(packet, normalized_callsign) + + socket = + socket + |> assign(:callsign, normalized_callsign) + |> assign(:packet, packet) + |> assign(:neighbors, neighbors) + |> assign(:page_title, "APRS station #{normalized_callsign}") + + {:ok, socket} + end + + defp get_latest_packet(callsign) do + %{callsign: callsign, limit: 1} + |> Packets.get_recent_packets() + |> List.first() + end + + defp get_neighbors(nil, _callsign), do: [] + + defp get_neighbors(packet, callsign) do + lat = packet.lat + lon = packet.lon + + if is_nil(lat) or is_nil(lon) do + [] + else + # Simple bounding box for ~10km radius + delta = @neighbor_radius_km / 111.0 + min_lat = lat - delta + max_lat = lat + delta + min_lon = lon - delta + max_lon = lon + delta + opts = %{bounds: [min_lon, min_lat, max_lon, max_lat], limit: 50} + + opts + |> Packets.get_recent_packets() + |> Enum.filter(fn p -> + (p.sender != callsign and p.lat) && p.lon + end) + |> uniq_by(& &1.sender) + |> Enum.map(fn p -> + dist = haversine(lat, lon, p.lat, p.lon) + + %{ + callsign: p.sender, + distance: format_distance(dist), + last_heard: PacketUtils.get_timestamp(p), + packet: p + } + end) + |> Enum.sort_by(& &1.distance) + |> Enum.take(@neighbor_limit) + end + end + + defp uniq_by(list, fun) do + list + |> Enum.reduce({MapSet.new(), []}, fn item, {set, acc} -> + key = fun.(item) + + if MapSet.member?(set, key) do + {set, acc} + else + {MapSet.put(set, key), [item | acc]} + end + end) + |> elem(1) + |> Enum.reverse() + end + + defp haversine(lat1, lon1, lat2, lon2) do + # Returns distance in km + r = 6371 + dlat = :math.pi() / 180 * (lat2 - lat1) + dlon = :math.pi() / 180 * (lon2 - lon1) + + a = + :math.sin(dlat / 2) * :math.sin(dlat / 2) + + :math.cos(:math.pi() / 180 * lat1) * :math.cos(:math.pi() / 180 * lat2) * + :math.sin(dlon / 2) * :math.sin(dlon / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + r * c + end + + defp format_distance(km) when km < 1.0 do + "#{Float.round(km * 1000, 0)} m" + end + + defp format_distance(km) do + "#{Float.round(km, 2)} km" + end +end diff --git a/lib/aprs_web/live/info_live/show.html.heex b/lib/aprs_web/live/info_live/show.html.heex new file mode 100644 index 0000000..980625b --- /dev/null +++ b/lib/aprs_web/live/info_live/show.html.heex @@ -0,0 +1,127 @@ +<% {symbol_table_id, symbol_code} = AprsWeb.MapLive.PacketUtils.get_symbol_info(@packet || %{}) %> +<% symbol_table = if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %> +<% symbol_code = symbol_code || ">" %> +<% symbol_table_num = + case symbol_table do + "/" -> 0 + "\\" -> 1 + "]" -> 2 + _ -> 0 + end %> +<% symbol_code_ord = + symbol_code + |> String.to_charlist() + |> List.first() + |> (fn c -> if is_integer(c), do: c, else: 63 end).() %> +<% symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %> +<% symbol_index = symbol_code_ord - 33 %> + +
+

+ APRS station {@callsign} + <%= if @packet do %> + <% {symbol_table_id, symbol_code} = AprsWeb.MapLive.PacketUtils.get_symbol_info(@packet) %> + <% symbol_table = if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %> + <% symbol_code = symbol_code || ">" %> + <% symbol_table_num = + case symbol_table do + "/" -> 0 + "\\" -> 1 + "]" -> 2 + _ -> 0 + end %> + <% symbol_code_ord = + symbol_code + |> String.to_charlist() + |> List.first() + |> (fn c -> if is_integer(c), do: c, else: 63 end).() %> + <% symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %> + <% symbol_index = symbol_code_ord - 33 %> + APRS symbol + <% end %> +

+ <%= if @packet do %> +
+ {@packet.comment} +
+
+
+
+ Location: {@packet.lat}, {@packet.lon} +
+
+ Last position: {AprsWeb.MapLive.PacketUtils.get_timestamp( + @packet + )} +
+
Altitude: {@packet.altitude} ft
+
Course: {@packet.course}°
+
Speed: {@packet.speed} MPH
+
+
+
+ Device: {@packet.manufacturer} {@packet.equipment_type} +
+
Path: {@packet.path}
+
Raw: {@packet.raw_packet}
+
+
+

Stations near current position

+
+ + + + + + + + + + <%= for neighbor <- @neighbors do %> + + + + + + <% end %> + +
CallsignDistanceLast heard
+ <.link navigate={~p"/info/#{neighbor.callsign}"} class="hover:underline"> + {neighbor.callsign} + + <%= if neighbor.packet do %> + <% {symbol_table_id, symbol_code} = + AprsWeb.MapLive.PacketUtils.get_symbol_info(neighbor.packet) %> + <% symbol_table = + if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %> + <% symbol_code = symbol_code || ">" %> + <% symbol_table_num = + case symbol_table do + "/" -> 0 + "\\" -> 1 + "]" -> 2 + _ -> 0 + end %> + <% symbol_code_ord = + symbol_code + |> String.to_charlist() + |> List.first() + |> (fn c -> if is_integer(c), do: c, else: 63 end).() %> + <% symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %> + <% symbol_index = symbol_code_ord - 33 %> + APRS symbol + <% end %> + {neighbor.distance}{neighbor.last_heard}
+
+ <% else %> +
No recent packet data available for this callsign.
+ <% end %> +
diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex index fb27511..a3a43a5 100644 --- a/lib/aprs_web/router.ex +++ b/lib/aprs_web/router.ex @@ -38,6 +38,7 @@ defmodule AprsWeb.Router do live "/weather/:callsign", WeatherLive.CallsignView, :index live "/about", AboutLive, :index live "/api", ApiDocsLive, :index + live "/info/:callsign", InfoLive.Show, :show end live_session :map_pages, layout: {AprsWeb.Layouts, :map} do