aprs.me/lib/aprs_web/live/map_live/index.ex
2025-06-16 09:00:50 -05:00

898 lines
28 KiB
Elixir

defmodule AprsWeb.MapLive.Index do
@moduledoc """
LiveView for displaying real-time APRS packets on a map
"""
use AprsWeb, :live_view
alias Aprs.EncodingUtils
alias AprsWeb.Endpoint
alias AprsWeb.Helpers.AprsSymbols
alias Parser.Types.MicE
@default_center %{lat: 39.8283, lng: -98.5795}
@default_zoom 5
@ip_api_url "https://ip-api.com/json/"
@finch_name Aprs.Finch
@default_replay_speed 1000
@impl true
def mount(_params, _session, socket) do
# Calculate one hour ago for packet age filtering
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
socket =
assign(socket,
packets: [],
page_title: "APRS Map",
# Track visible packets by callsign
visible_packets: %{},
# Default bounds for USA
map_bounds: %{
north: 49.0,
south: 24.0,
east: -66.0,
west: -125.0
},
map_center: @default_center,
map_zoom: @default_zoom,
# Replay controls
replay_active: false,
replay_speed: @default_replay_speed,
replay_paused: false,
replay_packets: [],
replay_index: 0,
replay_timer_ref: nil,
replay_start_time: nil,
replay_end_time: nil,
# Map of packet IDs to packet data for historical packets
historical_packets: %{},
# Timestamp for filtering out old packets
packet_age_threshold: one_hour_ago,
# Flag to indicate if map is ready for replay
map_ready: false,
# Flag to prevent multiple replay starts
replay_started: false,
# Pending geolocation to zoom to after map is ready
pending_geolocation: nil
)
if connected?(socket) do
IO.puts("Socket is connected, attempting to get IP location")
Endpoint.subscribe("aprs_messages")
# Get IP-based location on initial load
IO.puts("Connect info: #{inspect(socket.private[:connect_info])}")
IO.puts("Peer data: #{inspect(socket.private[:connect_info][:peer_data])}")
ip =
case socket.private[:connect_info][:peer_data][:address] do
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
{a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
_ -> nil
end
IO.puts("Extracted IP address: #{inspect(ip)}")
if ip do
IO.puts("Starting IP geolocation task for IP: #{ip}")
# Start as a separate task and await the result
Task.start(fn ->
try do
get_ip_location(ip)
rescue
error ->
IO.puts("Error in IP geolocation task: #{inspect(error)}")
IO.puts("Stacktrace: #{inspect(__STACKTRACE__)}")
send(self(), {:ip_location, @default_center})
end
end)
else
IO.puts("No IP address found, skipping geolocation")
end
# Schedule regular cleanup of old packets from the map
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
# Schedule initialization of replay after a short delay
if connected?(socket), do: Process.send_after(self(), :initialize_replay, 2000)
end
{:ok, socket}
end
@impl true
def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do
handle_bounds_update(bounds, socket)
end
@impl true
def handle_event("update_bounds", %{"bounds" => bounds}, socket) do
handle_bounds_update(bounds, socket)
end
@impl true
def handle_event("locate_me", _params, socket) do
# Send JavaScript command to request browser geolocation
IO.puts("locate_me event received, requesting geolocation")
{:noreply, push_event(socket, "request_geolocation", %{})}
end
@impl true
def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
# Update map center and zoom when location is received
IO.puts("set_location event received with lat=#{lat}, lng=#{lng}")
# Ensure coordinates are floats
lat_float =
cond do
is_binary(lat) -> String.to_float(lat)
is_integer(lat) -> lat / 1.0
true -> lat
end
lng_float =
cond do
is_binary(lng) -> String.to_float(lng)
is_integer(lng) -> lng / 1.0
true -> lng
end
IO.puts("Sending zoom_to_location event from set_location with lat=#{lat_float}, lng=#{lng_float}")
socket =
socket
|> assign(map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
|> push_event("zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
{:noreply, socket}
end
@impl true
def handle_event("toggle_replay", _params, socket) do
if socket.assigns.replay_active do
# Stop replay
if socket.assigns.replay_timer_ref, do: Process.cancel_timer(socket.assigns.replay_timer_ref)
# Clear historical packets from the map
socket =
socket
|> push_event("clear_historical_packets", %{})
|> assign(
replay_active: false,
replay_timer_ref: nil,
replay_paused: false,
replay_packets: [],
replay_index: 0,
historical_packets: %{},
replay_started: false
)
# Restart replay after a short delay
Process.send_after(self(), :initialize_replay, 1000)
{:noreply, socket}
else
# If not active, the user manually requested a replay restart
{:noreply, start_historical_replay(socket)}
end
end
@impl true
def handle_event("pause_replay", _params, socket) do
if socket.assigns.replay_active do
if socket.assigns.replay_paused do
# Resume replay
timer_ref = Process.send_after(self(), :replay_next_packet, 1000)
{:noreply, assign(socket, replay_paused: false, replay_timer_ref: timer_ref)}
else
# Pause replay
if socket.assigns.replay_timer_ref, do: Process.cancel_timer(socket.assigns.replay_timer_ref)
{:noreply, assign(socket, replay_paused: true, replay_timer_ref: nil)}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do
speed_float = String.to_float(speed)
{:noreply, assign(socket, replay_speed: speed_float)}
end
@impl true
def handle_event("map_ready", _params, socket) do
IO.puts("Map ready event received")
socket = assign(socket, map_ready: true)
# If we have pending geolocation, zoom to it now
socket =
if socket.assigns.pending_geolocation do
%{lat: lat, lng: lng} = socket.assigns.pending_geolocation
IO.puts("Map ready - zooming to pending location: lat=#{lat}, lng=#{lng}")
push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
else
IO.puts("Map ready - no pending geolocation")
socket
end
{:noreply, socket}
end
defp handle_bounds_update(bounds, socket) do
# Update the map bounds from the client
map_bounds = %{
north: bounds["north"],
south: bounds["south"],
east: bounds["east"],
west: bounds["west"]
}
# Don't clear markers - let the client preserve those still in view
# Recalculate packet count based on new bounds
visible_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_callsign, packet} ->
within_bounds?(packet, map_bounds)
end)
|> Map.new()
# If replay is not active, update the replay packets based on the new bounds
socket =
if socket.assigns.replay_active do
socket
else
# Clear any existing replay data when bounds change
socket =
assign(socket,
replay_packets: [],
replay_index: 0,
historical_packets: %{},
map_ready: true
)
# Don't automatically start replay on every bounds change
# We'll handle this in initialize_replay to avoid too many restarts
socket
end
{:noreply, assign(socket, map_bounds: map_bounds, visible_packets: visible_packets)}
end
@impl true
def handle_info(msg, socket) do
case msg do
{:delayed_zoom, %{lat: lat, lng: lng}} ->
IO.puts("Processing delayed zoom to lat=#{lat}, lng=#{lng}")
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
{:noreply, socket}
{:ip_location, %{lat: lat, lng: lng}} ->
# Log IP location received
IO.puts("IP location received: lat=#{lat}, lng=#{lng}")
# Ensure we're using numeric values for coordinates
lat_float =
cond do
is_binary(lat) -> String.to_float(lat)
is_integer(lat) -> lat / 1.0
true -> lat
end
lng_float =
cond do
is_binary(lng) -> String.to_float(lng)
is_integer(lng) -> lng / 1.0
true -> lng
end
# Update map center first
socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
# If map is ready, zoom to location immediately, otherwise store for later
socket =
if socket.assigns.map_ready do
IO.puts("Map is ready, zooming to location immediately to lat=#{lat_float}, lng=#{lng_float}")
push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
else
IO.puts("Map not ready yet, storing location lat=#{lat_float}, lng=#{lng_float} for later")
assign(socket, pending_geolocation: %{lat: lat_float, lng: lng_float})
end
{:noreply, socket}
:initialize_replay ->
# Only start replay if it hasn't been started yet
if not socket.assigns.replay_started and socket.assigns.map_ready do
socket = start_historical_replay(socket)
{:noreply, assign(socket, replay_started: true)}
else
{:noreply, socket}
end
:replay_next_packet ->
handle_replay_next_packet(socket)
:cleanup_old_packets ->
# Clean up packets older than 1 hour from the map display
handle_cleanup_old_packets(socket)
%{event: "packet", payload: payload} ->
# Sanitize the packet to prevent encoding errors
sanitized_packet = EncodingUtils.sanitize_packet(payload)
# Add received timestamp if not present
sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now())
# Only process packets with position data that are within current map bounds
# AND are not older than 1 hour
if has_position_data?(sanitized_packet) &&
within_bounds?(sanitized_packet, socket.assigns.map_bounds) &&
packet_within_time_threshold?(sanitized_packet, socket.assigns.packet_age_threshold) do
# Convert to a simple map structure for JSON encoding
packet_data = build_packet_data(sanitized_packet)
# Only push if we have valid packet data
if packet_data do
# Generate a unique key for this packet
callsign_key =
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
# Update visible packets tracking
visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet)
# Push the packet to the client-side JavaScript
socket =
socket
|> push_event("new_packet", packet_data)
|> assign(visible_packets: visible_packets)
{:noreply, socket}
else
# Invalid packet data, skip it
{:noreply, socket}
end
else
# Ignore packets without position data, outside bounds, or too old
{:noreply, socket}
end
end
end
# Handle replaying the next historical packet
defp handle_replay_next_packet(socket) do
%{
replay_packets: packets,
replay_index: index,
replay_speed: speed,
historical_packets: historical_packets
} = socket.assigns
if index < length(packets) do
# Get the next packet and advance the index
packet = Enum.at(packets, index)
next_index = index + 1
# Convert to a simple map structure for JSON encoding
packet_data = build_packet_data(packet)
# Only process packets with valid position data
socket =
if packet_data do
# Add is_historical flag and timestamp
packet_data =
Map.merge(packet_data, %{
"is_historical" => true,
"timestamp" => if(Map.has_key?(packet, :received_at), do: DateTime.to_iso8601(packet.received_at))
})
# Generate a unique key for this packet
packet_id = "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}"
# Update historical packets tracking
new_historical_packets = Map.put(historical_packets, packet_id, packet)
# Send packet data to client
socket
|> push_event("historical_packet", packet_data)
|> assign(
replay_index: next_index,
historical_packets: new_historical_packets
)
else
# Skip packets without position data, just advance the index
assign(socket, replay_index: next_index)
end
# Calculate delay for next packet (faster based on replay speed)
delay_ms = trunc(1000 / speed)
# Schedule the next packet
timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms)
{:noreply, assign(socket, replay_timer_ref: timer_ref)}
else
# All packets replayed, start over with a short delay
Process.send_after(self(), :initialize_replay, 10_000)
# Set active to false but don't clear packets - will auto-restart
socket =
assign(socket,
replay_active: false,
replay_timer_ref: nil,
replay_started: false
)
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"
/>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
>
</script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js">
</script>
<style>
#aprs-map {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
z-index: 1;
}
.locate-button {
position: absolute;
left: 10px;
top: 80px;
z-index: 1000;
background: white;
border: 2px solid rgba(0,0,0,0.2);
border-radius: 4px;
padding: 5px;
cursor: pointer;
}
.locate-button:hover {
background: #f4f4f4;
}
.aprs-marker {
background: transparent !important;
border: none !important;
}
.historical-marker {
opacity: 0.7;
}
.aprs-popup {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
line-height: 1.4;
max-width: 200px;
}
.aprs-callsign {
font-size: 14px;
font-weight: bold;
color: #1e40af;
margin-bottom: 4px;
}
.aprs-symbol-info {
color: #6b7280;
font-style: italic;
margin-bottom: 4px;
}
.aprs-comment {
color: #374151;
margin-bottom: 4px;
word-wrap: break-word;
}
.aprs-coords {
color: #6b7280;
font-size: 11px;
font-family: monospace;
}
/* High-DPI support for APRS symbols */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.aprs-marker div[style*="aprs-symbols-24-0.png"] {
background-image: url('/aprs-symbols/aprs-symbols-24-0@2x.png') !important;
background-size: 384px 144px !important;
}
.aprs-marker div[style*="aprs-symbols-24-1.png"] {
background-image: url('/aprs-symbols/aprs-symbols-24-1@2x.png') !important;
background-size: 384px 144px !important;
}
.aprs-marker div[style*="aprs-symbols-24-2.png"] {
background-image: url('/aprs-symbols/aprs-symbols-24-2@2x.png') !important;
background-size: 384px 144px !important;
}
}
/* Leaflet popup improvements for APRS data */
.leaflet-popup-content-wrapper {
border-radius: 8px;
}
.leaflet-popup-content {
margin: 8px 12px;
}
</style>
<div
id="aprs-map"
phx-hook="APRSMap"
phx-update="ignore"
data-center={Jason.encode!(@map_center)}
data-zoom={@map_zoom}
data-lat={@map_center.lat}
data-lng={@map_center.lng}
>
</div>
<button class="locate-button" phx-click="locate_me" title="Find my location">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
</button>
"""
end
# Handle cleanup of old packets
defp handle_cleanup_old_packets(socket) do
# Update the packet age threshold to current time minus one hour
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
# Filter out packets older than one hour
visible_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago)
end)
|> Map.new()
# Push event to remove old markers from the map
socket =
socket
|> push_event("refresh_markers", %{})
|> assign(
visible_packets: visible_packets,
packet_age_threshold: one_hour_ago
)
# Schedule the next cleanup in 1 minute
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
{:noreply, socket}
end
# Check if a packet is within the time threshold (not too old)
defp packet_within_time_threshold?(packet, threshold) do
case packet do
%{received_at: received_at} when not is_nil(received_at) ->
DateTime.compare(received_at, threshold) in [:gt, :eq]
_ ->
# If no timestamp, treat as current
true
end
end
# Helper functions
# Fetch historical packets from the database
# Helper function to start historical replay
defp start_historical_replay(socket) do
# Get time range for historical data
now = DateTime.utc_now()
one_hour_ago = DateTime.add(now, -60 * 60, :second)
# Convert map bounds to the format expected by the database query
bounds = [
socket.assigns.map_bounds.west,
socket.assigns.map_bounds.south,
socket.assigns.map_bounds.east,
socket.assigns.map_bounds.north
]
# Fetch historical packets with position data within the current map bounds
historical_packets = fetch_historical_packets(bounds, one_hour_ago, now)
if Enum.empty?(historical_packets) do
# No historical packets found - silently continue without replay
socket
else
# Clear any previous historical packets from the map
socket = push_event(socket, "clear_historical_packets", %{})
# Start replay
timer_ref = Process.send_after(self(), :replay_next_packet, 1000)
assign(socket,
replay_active: true,
replay_packets: historical_packets,
replay_index: 0,
replay_timer_ref: timer_ref,
replay_start_time: one_hour_ago,
replay_end_time: now,
historical_packets: %{},
replay_started: true
)
end
end
# Fetch historical packets from the database
defp fetch_historical_packets(bounds, start_time, end_time) do
# Force start_time to be at most 1 hour ago
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
effective_start_time =
if DateTime.before?(start_time, one_hour_ago),
do: one_hour_ago,
else: start_time
# Use the Packets context to retrieve historical packets
packets_params = %{
bounds: bounds,
start_time: effective_start_time,
end_time: end_time,
with_position: true,
# Reasonable limit to prevent overwhelming the client
limit: 1000
}
# Call the database through the Packets context
packets = Aprs.Packets.get_packets_for_replay(packets_params)
# Sort packets by received_at timestamp to ensure chronological replay
Enum.sort_by(packets, fn packet -> packet.received_at end)
end
defp has_position_data?(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# MicE packets have lat/lon in separate components
is_number(mic_e.lat_degrees) && is_number(mic_e.lat_minutes) &&
is_number(mic_e.lon_degrees) && is_number(mic_e.lon_minutes)
%{latitude: lat, longitude: lon} ->
# Regular position packets have decimal lat/lon
is_number(lat) && is_number(lon)
_ ->
false
end
end
defp within_bounds?(packet, bounds) do
{lat, lng} = get_coordinates(packet)
lat && lng &&
lat >= bounds.south && lat <= bounds.north &&
lng >= bounds.west && lng <= bounds.east
end
defp get_coordinates(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
{lat, lng}
else
IO.puts("Invalid MicE coordinates: lat=#{lat}, lng=#{lng}")
{nil, nil}
end
%{latitude: lat, longitude: lon} ->
{lat, lon}
_ ->
{nil, nil}
end
end
defp build_packet_data(packet) do
data_extended = build_data_extended(packet.data_extended)
{lat, lng} = get_coordinates(packet)
# Only include packets with valid position data
if lat && lng do
# Get symbol information from data_extended
symbol_table_id =
get_in(data_extended, ["symbol_table_id"]) ||
packet.symbol_table_id || "/"
symbol_code =
get_in(data_extended, ["symbol_code"]) ||
packet.symbol_code || ">"
# Validate symbol
{final_table_id, final_symbol_code} =
if AprsSymbols.valid_symbol?(symbol_table_id, symbol_code) do
{symbol_table_id, symbol_code}
else
AprsSymbols.default_symbol()
end
# Generate callsign for display
callsign =
if packet.ssid && packet.ssid != "" do
"#{packet.base_callsign}-#{packet.ssid}"
else
packet.base_callsign || ""
end
%{
"id" => callsign,
"callsign" => callsign,
"base_callsign" => packet.base_callsign || "",
"ssid" => packet.ssid || "",
"lat" => lat,
"lng" => lng,
"symbol_table_id" => final_table_id,
"symbol_code" => final_symbol_code,
"symbol_description" => AprsSymbols.symbol_description(final_table_id, final_symbol_code),
"data_type" => to_string(packet.data_type || "unknown"),
"path" => packet.path || "",
"comment" => get_in(data_extended, ["comment"]) || "",
"data_extended" => data_extended || %{}
}
# Return nil for packets without position data
end
end
# Get IP location from external service
# Get location from IP using ip-api.com
defp get_ip_location(nil), do: nil
defp get_ip_location(ip) do
url = "#{@ip_api_url}#{ip}"
IO.puts("Fetching location for IP: #{ip} from URL: #{url}")
# Add headers to make the request more likely to succeed
request =
Finch.build(:get, url, [
{"User-Agent", "APRS.me/1.0"},
{"Accept", "application/json"}
])
# Add a small delay to ensure the client is connected
Process.sleep(2000)
IO.puts("Making HTTP request to IP API...")
case Finch.request(request, @finch_name, receive_timeout: 10_000) do
{:ok, %{status: 200, body: body}} ->
IO.puts("IP API response received successfully")
IO.puts("Response body: #{String.slice(body, 0, 200)}...")
case Jason.decode(body) do
{:ok, %{"status" => "success", "lat" => lat, "lon" => lng}} when is_number(lat) and is_number(lng) ->
IO.puts("Valid coordinates found: lat=#{lat}, lng=#{lng}")
if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
IO.puts("Sending IP location message with coordinates")
# Force numeric values
lat_float = lat / 1.0
lng_float = lng / 1.0
send(self(), {:ip_location, %{lat: lat_float, lng: lng_float}})
else
IO.puts("Coordinates out of range: lat=#{lat}, lng=#{lng}, using default center")
send(self(), {:ip_location, @default_center})
end
{:ok, %{"status" => status}} ->
IO.puts("IP API returned non-success status: #{status}")
send(self(), {:ip_location, @default_center})
{:ok, data} ->
IO.puts("IP API returned unexpected format: #{inspect(data)}")
send(self(), {:ip_location, @default_center})
{:error, decode_error} ->
IO.puts("Failed to decode JSON response: #{inspect(decode_error)}")
IO.puts("Raw response: #{body}")
send(self(), {:ip_location, @default_center})
end
{:ok, response} ->
IO.puts("IP API request failed with status: #{response.status}")
IO.puts("Response headers: #{inspect(response.headers)}")
IO.puts("Response body: #{String.slice(response.body || "", 0, 200)}...")
send(self(), {:ip_location, @default_center})
{:error, %{reason: :timeout}} ->
IO.puts("IP API request timed out after 10 seconds")
send(self(), {:ip_location, @default_center})
{:error, error} ->
IO.puts("IP API request error: #{inspect(error)}")
send(self(), {:ip_location, @default_center})
end
end
defp build_data_extended(nil), do: nil
defp build_data_extended(data_extended) do
case data_extended do
%MicE{} = mic_e ->
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
%{
"latitude" => lat,
"longitude" => lng,
"comment" => mic_e.message || "",
"symbol_table_id" => "/",
"symbol_code" => ">",
"aprs_messaging" => false
}
end
_ ->
%{
"latitude" => data_extended[:latitude],
"longitude" => data_extended[:longitude],
"comment" => data_extended[:comment] || "",
"symbol_table_id" => data_extended[:symbol_table_id] || "/",
"symbol_code" => data_extended[:symbol_code] || ">",
"aprs_messaging" => data_extended[:aprs_messaging] || false
}
end
end
end