From f575863e7f26b5773ff913f944fdbfdd210351cc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 21 Jun 2025 22:01:59 -0500 Subject: [PATCH] DRY and list scrolling --- assets/css/app.css | 191 ------------ lib/aprs_web/components/layouts.ex | 16 +- lib/aprs_web/components/layouts/app.html.heex | 2 +- lib/aprs_web/components/layouts/map.html.heex | 213 ++++++++++++++ lib/aprs_web/live/map_live/callsign_view.ex | 106 +------ lib/aprs_web/live/map_live/index.ex | 276 ++++++++---------- lib/aprs_web/live/map_live/packet_utils.ex | 137 +++++++++ lib/aprs_web/router.ex | 17 +- lib/parser/mic_e.ex | 141 +++++---- 9 files changed, 582 insertions(+), 517 deletions(-) create mode 100644 lib/aprs_web/components/layouts/map.html.heex create mode 100644 lib/aprs_web/live/map_live/packet_utils.ex diff --git a/assets/css/app.css b/assets/css/app.css index eb05043..258c7b1 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -4,87 +4,6 @@ /* This file is for your main application CSS */ -/* Ensure full height layout for map pages */ -html, -body { - height: 100%; - margin: 0; - padding: 0; -} - -/* Main content container for map pages */ -main { - height: 100vh; - overflow: hidden; -} - -#map { - height: calc(100vh - 60px); - /* Adjust based on header height */ - width: 100%; -} - -/* Full page map for APRS home page and callsign pages */ -#aprs-map { - position: fixed !important; - top: 0 !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - height: 100vh !important; - width: 100vw !important; - z-index: 1 !important; - transition: right 0.3s ease-in-out !important; -} - -/* Desktop slideover behavior for map */ -@media (min-width: 1024px) { - #aprs-map.slideover-open { - right: 352px !important; - } - - #aprs-map.slideover-closed { - right: 0 !important; - } -} - -/* Mobile slideover behavior for map */ -@media (max-width: 1023px) { - #aprs-map { - right: 0 !important; - } -} - -/* Ensure the map container div has proper dimensions */ -.phx-main { - position: relative; - height: 100vh; - overflow: hidden; -} - -/* Fix for LiveView containers */ -div[data-phx-main="true"] { - height: 100vh; - overflow: hidden; -} - -/* Hide header on home page */ -body.home-page header { - display: none; -} - -/* Adjust main content area for full page map */ -body.home-page main { - padding: 0; - max-width: none; - height: 100vh; -} - -body.home-page main > div { - max-width: none; - height: 100%; -} - /* Marker cluster styles */ .marker-cluster-small { background-color: rgba(16, 185, 129, 0.8); @@ -292,113 +211,3 @@ body.home-page main > div { .historical-dot-marker { z-index: 15 !important; } - -/* Slideover toggle button styles */ -.slideover-toggle { - position: fixed !important; - right: 10px; - top: 10px; - z-index: 1001; - background: white; - border: 2px solid rgba(0, 0, 0, 0.2); - border-radius: 8px; - padding: 10px; - cursor: pointer; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; -} - -.slideover-toggle:hover { - background: #f8fafc; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); - transform: translateY(-1px); -} - -.slideover-toggle:active { - transform: translateY(0); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -/* Hide toggle on desktop when slideover is open */ -@media (min-width: 1024px) { - .slideover-toggle.slideover-open { - display: none; - } -} - -/* Mobile toggle adjustments */ -@media (max-width: 1023px) { - .slideover-toggle { - right: 15px; - top: 15px; - padding: 12px; - border-radius: 50%; - width: 48px; - height: 48px; - } -} - -/* Slideover panel responsive styles */ -.slideover-panel { - position: fixed; - top: 0; - right: 0; - height: 100vh; - width: 352px; - background: white; - box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); - z-index: 1000; - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - overflow-y: auto; -} - -/* Desktop slideover behavior */ -@media (min-width: 1024px) { - .slideover-panel.slideover-open { - transform: translateX(0); - } - - .slideover-panel.slideover-closed { - transform: translateX(100%); - } -} - -/* Mobile slideover behavior */ -@media (max-width: 1023px) { - .slideover-panel { - width: 100vw; - max-width: 400px; - box-shadow: -8px 0 32px rgba(0, 0, 0, 0.2); - } - - .slideover-panel.slideover-open { - transform: translateX(0); - } - - .slideover-panel.slideover-closed { - transform: translateX(100%); - } -} - -/* Mobile panel improvements */ -@media (max-width: 1023px) { - .slideover-panel { - display: flex; - flex-direction: column; - } -} - -/* Improve scrolling on mobile */ -@media (max-width: 1023px) { - .slideover-panel { - -webkit-overflow-scrolling: touch; - overscroll-behavior: contain; - } - - .slideover-panel .flex-1 { - min-height: 0; - } -} diff --git a/lib/aprs_web/components/layouts.ex b/lib/aprs_web/components/layouts.ex index e905149..0c059e2 100644 --- a/lib/aprs_web/components/layouts.ex +++ b/lib/aprs_web/components/layouts.ex @@ -4,19 +4,7 @@ defmodule AprsWeb.Layouts do embed_templates "layouts/*" - def body_class(assigns) do - classes = ["bg-white antialiased"] - - # Check if this is the home page (map) - is_home = - cond do - # For regular controller requests - assigns[:conn] && assigns.conn.request_path == "/" -> true - # For LiveView requests - assigns[:socket] && assigns.socket.view == AprsWeb.MapLive.Index -> true - true -> false - end - - if is_home, do: ["home-page" | classes], else: classes + def body_class(_assigns) do + ["bg-white antialiased"] end end diff --git a/lib/aprs_web/components/layouts/app.html.heex b/lib/aprs_web/components/layouts/app.html.heex index 160e5f4..17da568 100644 --- a/lib/aprs_web/components/layouts/app.html.heex +++ b/lib/aprs_web/components/layouts/app.html.heex @@ -1,4 +1,4 @@ -
+
<.flash kind={:info} title="Success!" flash={@flash} /> <.flash kind={:error} title="Error!" flash={@flash} /> diff --git a/lib/aprs_web/components/layouts/map.html.heex b/lib/aprs_web/components/layouts/map.html.heex new file mode 100644 index 0000000..f04ae1f --- /dev/null +++ b/lib/aprs_web/components/layouts/map.html.heex @@ -0,0 +1,213 @@ + + +
+
+ <.flash kind={:info} title="Success!" flash={@flash} /> + <.flash kind={:error} title="Error!" flash={@flash} /> + <.flash + id="disconnected" + kind={:error} + title="We can't find the internet" + close={false} + autoshow={false} + phx-disconnected={show("#disconnected")} + phx-connected={hide("#disconnected")} + > + Attempting to reconnect + + {@inner_content} +
+
diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index e4a1ee2..08412cf 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -5,6 +5,7 @@ defmodule AprsWeb.MapLive.CallsignView do alias Aprs.Packets alias AprsWeb.Endpoint alias AprsWeb.MapLive.MapHelpers + alias AprsWeb.MapLive.PacketUtils @default_center %{lat: 39.0, lng: -98.0} @default_zoom 4 @@ -864,74 +865,17 @@ defmodule AprsWeb.MapLive.CallsignView do defp build_packet_map(packet, lat, lng, data_extended) do data_extended = data_extended || %{} - - callsign = - case {Map.get(packet, :base_callsign), Map.get(packet, :ssid)} do - {base, ssid} when is_binary(base) and ssid not in [nil, "", "0"] -> - "#{base}-#{ssid}" - - {base, _} -> - base || "" - end - - symbol_table_id = - Map.get(data_extended, :symbol_table_id) || - Map.get(data_extended, "symbol_table_id") || - Map.get(packet, :symbol_table_id) || - Map.get(packet, "symbol_table_id") || - "/" - - symbol_code = - Map.get(data_extended, :symbol_code) || - Map.get(data_extended, "symbol_code") || - Map.get(packet, :symbol_code) || - Map.get(packet, "symbol_code") || - ">" - - symbol_description = - Map.get(data_extended, :symbol_description) || Map.get(data_extended, "symbol_description") || - "Symbol: #{symbol_table_id}#{symbol_code}" - - timestamp = - cond do - Map.has_key?(packet, :received_at) && packet.received_at -> - DateTime.to_iso8601(packet.received_at) - - Map.has_key?(packet, "received_at") && packet["received_at"] -> - DateTime.to_iso8601(packet["received_at"]) - - true -> - "" - end - - comment = Map.get(data_extended, :comment) || Map.get(data_extended, "comment") || "" - - to_float = fn - %Decimal{} = d -> - Decimal.to_float(d) - - n when is_float(n) -> - n - - n when is_integer(n) -> - n * 1.0 - - n when is_binary(n) -> - case Float.parse(n) do - {f, _} -> f - :error -> 0.0 - end - - _ -> - 0.0 - end + 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, "") popup = """
-
#{symbol_description}
+
Symbol: #{symbol_table_id}#{symbol_code}
#{if comment == "", do: "", else: "
#{comment}
"} -
#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lng), 4)}
+
#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lng), 4)}
#{timestamp}
""" @@ -939,17 +883,17 @@ defmodule AprsWeb.MapLive.CallsignView do %{ "id" => callsign, "callsign" => callsign, - "base_callsign" => Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")), - "ssid" => Map.get(packet, :ssid, Map.get(packet, "ssid", 0)), - "lat" => to_float.(lat), - "lng" => to_float.(lng), - "data_type" => to_string(Map.get(packet, :data_type, Map.get(packet, "data_type", "unknown"))), - "path" => Map.get(packet, :path, Map.get(packet, "path", "")), + "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" => convert_tuples_to_strings(data_extended || %{}), + "data_extended" => PacketUtils.convert_tuples_to_strings(data_extended || %{}), "symbol_table_id" => symbol_table_id, "symbol_code" => symbol_code, - "symbol_description" => symbol_description, + "symbol_description" => "Symbol: #{symbol_table_id}#{symbol_code}", "timestamp" => timestamp, "popup" => popup } @@ -998,26 +942,6 @@ defmodule AprsWeb.MapLive.CallsignView do end end - defp convert_tuples_to_strings(map) when is_map(map) do - if Map.has_key?(map, :__struct__) do - map - else - Map.new(map, fn {k, v} -> - {k, convert_tuples_to_strings(v)} - end) - end - end - - defp convert_tuples_to_strings(list) when is_list(list) do - Enum.map(list, &convert_tuples_to_strings/1) - end - - defp convert_tuples_to_strings(tuple) when is_tuple(tuple) do - to_string(inspect(tuple)) - end - - defp convert_tuples_to_strings(other), do: other - defp load_callsign_packets(socket, callsign) do # Load only the latest packet for this specific callsign latest_packet = diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 44549ae..2e732a5 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -6,6 +6,7 @@ defmodule AprsWeb.MapLive.Index do alias AprsWeb.Endpoint alias AprsWeb.MapLive.MapHelpers + alias AprsWeb.MapLive.PacketUtils alias Phoenix.LiveView.Socket require Logger @@ -411,40 +412,53 @@ defmodule AprsWeb.MapLive.Index do defp handle_info_postgres_packet(packet, socket) do {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) + callsign_key = get_callsign_key(packet) - callsign_key = - if Map.has_key?(packet, "id"), - do: to_string(packet["id"]), - else: System.unique_integer([:positive]) - - # Logger.debug( - # "[MAP] Incoming packet: id=#{inspect(callsign_key)} lat=#{inspect(lat)} lon=#{inspect(lon)} bounds=#{inspect(socket.assigns.map_bounds)} within_bounds?=#{inspect(MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds))}" - # ) - + # Update all_packets all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet) socket = assign(socket, all_packets: all_packets) - # Remove marker if packet is out of bounds but present - if !is_nil(lat) and !is_nil(lon) and - Map.has_key?(socket.assigns.visible_packets, callsign_key) and - not MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do - socket = push_event(socket, "remove_marker", %{id: callsign_key}) - new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key) - {:noreply, assign(socket, visible_packets: new_visible_packets)} - # Only add marker if it is within bounds and not already present - else - if is_nil(lat) or is_nil(lon) or Map.has_key?(socket.assigns.visible_packets, callsign_key) do + # Handle packet visibility logic + handle_packet_visibility(packet, lat, lon, callsign_key, socket) + end + + defp get_callsign_key(packet) do + if Map.has_key?(packet, "id"), + do: to_string(packet["id"]), + else: System.unique_integer([:positive]) + end + + defp handle_packet_visibility(packet, lat, lon, callsign_key, socket) do + cond do + should_remove_marker?(lat, lon, callsign_key, socket) -> + remove_marker_from_map(callsign_key, socket) + + should_add_marker?(lat, lon, callsign_key, socket) -> + handle_valid_postgres_packet(packet, lat, lon, socket) + + true -> {:noreply, socket} - else - if MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do - handle_valid_postgres_packet(packet, lat, lon, socket) - else - {:noreply, socket} - end - end end end + defp should_remove_marker?(lat, lon, callsign_key, socket) do + !is_nil(lat) and !is_nil(lon) and + Map.has_key?(socket.assigns.visible_packets, callsign_key) and + not MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) + end + + defp should_add_marker?(lat, lon, callsign_key, socket) do + !is_nil(lat) and !is_nil(lon) and + not Map.has_key?(socket.assigns.visible_packets, callsign_key) and + MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) + end + + defp remove_marker_from_map(callsign_key, socket) do + socket = push_event(socket, "remove_marker", %{id: callsign_key}) + new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key) + {:noreply, assign(socket, visible_packets: new_visible_packets)} + end + defp handle_valid_postgres_packet(packet, _lat, _lon, socket) do # Add the packet to visible_packets and push a marker immediately callsign_key = @@ -1082,9 +1096,9 @@ defmodule AprsWeb.MapLive.Index do defp filter_unique_positions(packets) do packets |> Enum.reduce([], fn packet, acc -> - {lat, lng, _} = MapHelpers.get_coordinates(packet) + {lat, lon, _} = MapHelpers.get_coordinates(packet) - if lat && lng do + if lat && lon do case acc do [] -> # First packet, always include @@ -1278,154 +1292,98 @@ defmodule AprsWeb.MapLive.Index do @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 || %{} - callsign = generate_callsign(packet) + packet_info = extract_packet_info(packet, data_extended) + popup = build_popup_content(packet, packet_info, lat, lon) - symbol_table_id = - Map.get(data_extended, :symbol_table_id) || - Map.get(data_extended, "symbol_table_id") || - Map.get(packet, :symbol_table_id) || - Map.get(packet, "symbol_table_id") || - "/" - - symbol_code = - Map.get(data_extended, :symbol_code) || - Map.get(data_extended, "symbol_code") || - Map.get(packet, :symbol_code) || - Map.get(packet, "symbol_code") || - ">" - - symbol_description = - Map.get(data_extended, :symbol_description) || Map.get(data_extended, "symbol_description") || - "Symbol: #{symbol_table_id}#{symbol_code}" - - timestamp = - cond do - Map.has_key?(packet, :received_at) && packet.received_at -> - DateTime.to_iso8601(packet.received_at) - - Map.has_key?(packet, "received_at") && packet["received_at"] -> - DateTime.to_iso8601(packet["received_at"]) - - true -> - "" - end - - comment = Map.get(data_extended, :comment) || Map.get(data_extended, "comment") || "" - - # Recursively convert tuples in data_extended to strings - safe_data_extended = convert_tuples_to_strings(data_extended) - - to_float = fn - %Decimal{} = d -> - Decimal.to_float(d) - - n when is_float(n) -> - n - - n when is_integer(n) -> - n * 1.0 - - n when is_binary(n) -> - case Float.parse(n) do - {f, _} -> f - :error -> 0.0 - end - - _ -> - 0.0 - end - - is_weather_packet = - (Map.get(packet, :data_type) || Map.get(packet, "data_type")) == "weather" or - (symbol_table_id == "/" and symbol_code == "_") - - popup = - if is_weather_packet do - build_weather_popup_html(packet, callsign) - else - """ -
- - #{if comment == "", do: "", else: ~s(
#{comment}
)} -
#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lon), 4)}
-
#{timestamp}
-
- """ - end + build_packet_result(packet, packet_info, lat, lon, popup) + end + defp extract_packet_info(packet, data_extended) do %{ - "id" => callsign, - "callsign" => callsign, - "base_callsign" => Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")), - "ssid" => Map.get(packet, :ssid, Map.get(packet, "ssid", 0)), - "lat" => to_float.(lat), - "lng" => to_float.(lon), - "data_type" => to_string(Map.get(packet, :data_type, Map.get(packet, "data_type", "unknown"))), - "path" => Map.get(packet, :path, Map.get(packet, "path", "")), - "comment" => comment, - "data_extended" => safe_data_extended || %{}, - "symbol_table_id" => symbol_table_id, - "symbol_code" => symbol_code, - "symbol_description" => symbol_description, - "timestamp" => timestamp, + 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.is_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(
#{packet_info.comment}
) + + """ +
+ + #{comment_html} +
#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lon), 4)}
+
#{packet_info.timestamp}
+
+ """ + 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 convert_tuples_to_strings(map) when is_map(map) do - if Map.has_key?(map, :__struct__) do - map - else - Map.new(map, fn {k, v} -> - {k, convert_tuples_to_strings(v)} - end) - end - end - - defp convert_tuples_to_strings(list) when is_list(list) do - Enum.map(list, &convert_tuples_to_strings/1) - end - - defp convert_tuples_to_strings(tuple) when is_tuple(tuple) do - to_string(inspect(tuple)) - end - - defp convert_tuples_to_strings(other), do: other - defp build_weather_popup_html(packet, callsign) do - received_at = - cond do - Map.has_key?(packet, :received_at) -> packet.received_at - Map.has_key?(packet, "received_at") -> packet["received_at"] - true -> nil - end - - timestamp_str = - if received_at, - do: Calendar.strftime(received_at, "%Y-%m-%d %H:%M:%S"), - else: "N/A" + received_at = get_received_at(packet) + timestamp_str = format_timestamp(received_at) """ #{callsign} - Weather Report
#{timestamp_str} UTC
- 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.
+ 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_weather_field(packet, key) do - data_extended = Map.get(packet, "data_extended", %{}) + 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 - Map.get(packet, key) || - Map.get(packet, to_string(key)) || - Map.get(data_extended, key) || - Map.get(data_extended, to_string(key)) || "N/A" + defp format_timestamp(received_at) do + if received_at, + do: Calendar.strftime(received_at, "%Y-%m-%d %H:%M:%S"), + else: "N/A" end @spec generate_callsign(map() | struct()) :: String.t() diff --git a/lib/aprs_web/live/map_live/packet_utils.ex b/lib/aprs_web/live/map_live/packet_utils.ex new file mode 100644 index 0000000..040c472 --- /dev/null +++ b/lib/aprs_web/live/map_live/packet_utils.ex @@ -0,0 +1,137 @@ +defmodule AprsWeb.MapLive.PacketUtils do + @moduledoc """ + Shared utilities for extracting and processing packet data in map views. + """ + + @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. + """ + @spec get_packet_field(map(), atom() | String.t(), any()) :: any() + def get_packet_field(packet, field, default \\ nil) do + data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{} + + Map.get(packet, field) || + Map.get(packet, to_string(field)) || + Map.get(data_extended, field) || + Map.get(data_extended, to_string(field)) || + default + end + + @doc """ + Extracts symbol information from a packet with fallbacks. + """ + @spec get_symbol_info(map()) :: {String.t(), String.t()} + def get_symbol_info(packet) do + symbol_table_id = get_packet_field(packet, :symbol_table_id, "/") + symbol_code = get_packet_field(packet, :symbol_code, ">") + + {symbol_table_id, symbol_code} + end + + @doc """ + Extracts timestamp from a packet in ISO8601 format. + """ + @spec get_timestamp(map()) :: String.t() + def get_timestamp(packet) do + cond do + Map.has_key?(packet, :received_at) && packet.received_at -> + DateTime.to_iso8601(packet.received_at) + + Map.has_key?(packet, "received_at") && packet["received_at"] -> + DateTime.to_iso8601(packet["received_at"]) + + true -> + "" + end + end + + @doc """ + Converts various numeric types to float for consistent display. + """ + @spec to_float(any()) :: float() + def to_float(value) do + case value do + %Decimal{} = d -> + Decimal.to_float(d) + + n when is_float(n) -> + n + + n when is_integer(n) -> + n * 1.0 + + n when is_binary(n) -> + case Float.parse(n) do + {f, _} -> f + :error -> 0.0 + end + + _ -> + 0.0 + end + end + + @doc """ + Generates a callsign string with SSID if present. + """ + @spec generate_callsign(map()) :: String.t() + def generate_callsign(packet) do + base_callsign = get_packet_field(packet, :base_callsign, "") + ssid = get_packet_field(packet, :ssid, 0) + + if ssid != 0 and ssid != "" and ssid != nil do + "#{base_callsign}-#{ssid}" + else + base_callsign + end + end + + @doc """ + Determines if a packet is a weather packet. + """ + @spec is_weather_packet?(map()) :: boolean() + def is_weather_packet?(packet) do + data_type = get_packet_field(packet, :data_type, "") + {symbol_table_id, symbol_code} = get_symbol_info(packet) + + data_type == "weather" or (symbol_table_id == "/" and symbol_code == "_") + end + + @doc """ + Recursively converts tuples in data structures to strings for JSON serialization. + """ + @spec convert_tuples_to_strings(any()) :: any() + def convert_tuples_to_strings(map) when is_map(map) do + if Map.has_key?(map, :__struct__) do + map + else + Map.new(map, fn {k, v} -> + {k, convert_tuples_to_strings(v)} + end) + end + end + + def convert_tuples_to_strings(list) when is_list(list) do + Enum.map(list, &convert_tuples_to_strings/1) + end + + def convert_tuples_to_strings(tuple) when is_tuple(tuple) do + to_string(inspect(tuple)) + end + + def convert_tuples_to_strings(other), do: other + + @doc """ + Extracts weather field data with fallback support. + """ + @spec get_weather_field(map(), atom()) :: String.t() + def get_weather_field(packet, key) do + data_extended = Map.get(packet, "data_extended", %{}) || %{} + + Map.get(packet, key) || + Map.get(packet, to_string(key)) || + Map.get(data_extended, key) || + Map.get(data_extended, to_string(key)) || "N/A" + end +end diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex index eba5664..2f1706e 100644 --- a/lib/aprs_web/router.ex +++ b/lib/aprs_web/router.ex @@ -29,13 +29,18 @@ defmodule AprsWeb.Router do pipe_through :browser live_dashboard "/dashboard", metrics: AprsWeb.Telemetry - live "/", MapLive.Index, :index - live "/status", StatusLive.Index, :index - live "/packets", PacketsLive.Index, :index - live "/packets/:callsign", PacketsLive.CallsignView, :index - live "/badpackets", BadPacketsLive.Index, :index - live "/:callsign", MapLive.CallsignView, :index + live_session :map_pages, layout: {AprsWeb.Layouts, :map} do + live "/", MapLive.Index, :index + live "/:callsign", MapLive.CallsignView, :index + end + + live_session :regular_pages do + live "/status", StatusLive.Index, :index + live "/packets", PacketsLive.Index, :index + live "/packets/:callsign", PacketsLive.CallsignView, :index + live "/badpackets", BadPacketsLive.Index, :index + end end # Other scopes may use custom stacks. diff --git a/lib/parser/mic_e.ex b/lib/parser/mic_e.ex index 3526767..3038a44 100644 --- a/lib/parser/mic_e.ex +++ b/lib/parser/mic_e.ex @@ -63,62 +63,12 @@ defmodule Parser.MicE do try do <> = destination - d1 = decode_digit(c1) - d2 = decode_digit(c2) - d3 = decode_digit(c3) - d4 = decode_digit(c4) - d5 = decode_digit(c5) - d6 = decode_digit(c6) + digits = decode_destination_digits([c1, c2, c3, c4, c5, c6]) + lat_info = calculate_latitude_info(digits, c4) + lon_info = calculate_longitude_info(c5, c6) + message_info = extract_message_info(digits) - lat_degrees = d1.digit * 10 + d2.digit - lat_minutes = d3.digit * 10 + d4.digit - lat_hundredths = d5.digit * 10 + d6.digit - - lat_direction = - case c4 do - c when c in ?0..?9 -> :south - ?L -> :south - c when c in ?P..?Z -> :north - _ -> :unknown - end - - longitude_offset = - case c5 do - c when c in ?0..?9 -> 0 - ?L -> 0 - c when c in ?P..?Z -> 100 - _ -> 0 - end - - lon_direction = - case c6 do - c when c in ?0..?9 -> :east - ?L -> :east - c when c in ?P..?Z -> :west - _ -> :unknown - end - - message_bits = {d1.msg_bit, d2.msg_bit, d3.msg_bit} - - message_type = - cond do - d1.msg_type != nil -> d1.msg_type - d2.msg_type != nil -> d2.msg_type - d3.msg_type != nil -> d3.msg_type - true -> nil - end - - {:ok, - %{ - lat_degrees: lat_degrees, - lat_minutes: lat_minutes, - lat_hundredths: lat_hundredths, - lat_direction: lat_direction, - lon_direction: lon_direction, - longitude_offset: longitude_offset, - message_bits: message_bits, - message_type: message_type - }} + {:ok, Map.merge(lat_info, Map.merge(lon_info, message_info))} rescue _ -> {:error, :invalid_character_in_destination} end @@ -127,6 +77,87 @@ defmodule Parser.MicE do end end + defp decode_destination_digits([c1, c2, c3, c4, c5, c6]) do + [ + decode_digit(c1), + decode_digit(c2), + decode_digit(c3), + decode_digit(c4), + decode_digit(c5), + decode_digit(c6) + ] + end + + defp calculate_latitude_info([d1, d2, d3, d4, d5, d6], c4) do + lat_degrees = d1.digit * 10 + d2.digit + lat_minutes = d3.digit * 10 + d4.digit + lat_hundredths = d5.digit * 10 + d6.digit + lat_direction = determine_lat_direction(c4) + + %{ + lat_degrees: lat_degrees, + lat_minutes: lat_minutes, + lat_hundredths: lat_hundredths, + lat_direction: lat_direction + } + end + + defp determine_lat_direction(c4) do + case c4 do + c when c in ?0..?9 -> :south + ?L -> :south + c when c in ?P..?Z -> :north + _ -> :unknown + end + end + + defp calculate_longitude_info(c5, c6) do + longitude_offset = determine_longitude_offset(c5) + lon_direction = determine_lon_direction(c6) + + %{ + lon_direction: lon_direction, + longitude_offset: longitude_offset + } + end + + defp determine_longitude_offset(c5) do + case c5 do + c when c in ?0..?9 -> 0 + ?L -> 0 + c when c in ?P..?Z -> 100 + _ -> 0 + end + end + + defp determine_lon_direction(c6) do + case c6 do + c when c in ?0..?9 -> :east + ?L -> :east + c when c in ?P..?Z -> :west + _ -> :unknown + end + end + + defp extract_message_info([d1, d2, d3, _d4, _d5, _d6]) do + message_bits = {d1.msg_bit, d2.msg_bit, d3.msg_bit} + message_type = determine_message_type([d1, d2, d3]) + + %{ + message_bits: message_bits, + message_type: message_type + } + end + + defp determine_message_type([d1, d2, d3]) do + cond do + d1.msg_type != nil -> d1.msg_type + d2.msg_type != nil -> d2.msg_type + d3.msg_type != nil -> d3.msg_type + true -> nil + end + end + defp decode_digit(char) do case char do c when c in ?0..?9 -> %{digit: c - ?0, msg_bit: 0, msg_type: nil}