DRY and list scrolling

This commit is contained in:
Graham McIntire 2025-06-21 22:01:59 -05:00
parent f27a6e2a4e
commit f575863e7f
No known key found for this signature in database
9 changed files with 582 additions and 517 deletions

View file

@ -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;
}
}

View file

@ -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

View file

@ -1,4 +1,4 @@
<main>
<main class="min-h-screen bg-white">
<div>
<.flash kind={:info} title="Success!" flash={@flash} />
<.flash kind={:error} title="Error!" flash={@flash} />

View file

@ -0,0 +1,213 @@
<style>
/* Map-specific CSS styles */
/* 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%;
}
/* 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;
}
}
</style>
<main>
<div>
<.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 <Heroicons.arrow_path class="ml-1 w-3 h-3 inline animate-spin" />
</.flash>
{@inner_content}
</div>
</main>

View file

@ -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 = """
<div class=\"aprs-popup\">
<div class=\"aprs-callsign\"><strong><a href=\"/#{callsign}\">#{callsign}</a></strong></div>
<div class=\"aprs-symbol-info\">#{symbol_description}</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(to_float.(lat), 4)}, #{Float.round(to_float.(lng), 4)}</div>
<div class=\"aprs-coords\">#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lng), 4)}</div>
<div class=\"aprs-time\">#{timestamp}</div>
</div>
"""
@ -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 =

View file

@ -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
"""
<div class="aprs-popup">
<div class="aprs-callsign"><strong><a href="/map/callsign/#{callsign}">#{callsign}</a></strong></div>
#{if comment == "", do: "", else: ~s(<div class="aprs-comment">#{comment}</div>)}
<div class="aprs-coords">#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lon), 4)}</div>
<div class="aprs-time">#{timestamp}</div>
</div>
"""
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(<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">#{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 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)
"""
<strong>#{callsign} - Weather Report</strong><br>
<small>#{timestamp_str} UTC</small>
<hr>
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>
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_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()

View file

@ -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

View file

@ -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.

View file

@ -63,62 +63,12 @@ defmodule Parser.MicE do
try do
<<c1, c2, c3, c4, c5, c6>> = 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}