1055 lines
32 KiB
Elixir
1055 lines
32 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
|
|
alias Phoenix.LiveView.Socket
|
|
|
|
@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
|
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
|
|
|
socket = assign_defaults(socket, one_hour_ago)
|
|
|
|
if connected?(socket) do
|
|
Endpoint.subscribe("aprs_messages")
|
|
maybe_start_geolocation(socket)
|
|
schedule_timers()
|
|
end
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
@spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t()
|
|
defp assign_defaults(socket, one_hour_ago) do
|
|
assign(socket,
|
|
packets: [],
|
|
page_title: "APRS Map",
|
|
visible_packets: %{},
|
|
map_bounds: %{
|
|
north: 49.0,
|
|
south: 24.0,
|
|
east: -66.0,
|
|
west: -125.0
|
|
},
|
|
map_center: @default_center,
|
|
map_zoom: @default_zoom,
|
|
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,
|
|
historical_packets: %{},
|
|
packet_age_threshold: one_hour_ago,
|
|
map_ready: false,
|
|
replay_started: false,
|
|
pending_geolocation: nil,
|
|
bounds_update_timer: nil,
|
|
pending_bounds: nil
|
|
)
|
|
end
|
|
|
|
@spec maybe_start_geolocation(Socket.t()) :: Socket.t()
|
|
defp maybe_start_geolocation(socket) do
|
|
if Application.get_env(:aprs, :disable_aprs_connection, false) != true do
|
|
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
|
|
|
|
if ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") do
|
|
Task.start(fn ->
|
|
try do
|
|
get_ip_location(ip)
|
|
rescue
|
|
_error ->
|
|
send(self(), {:ip_location, @default_center})
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
socket
|
|
end
|
|
|
|
defp schedule_timers do
|
|
Process.send_after(self(), :cleanup_old_packets, 60_000)
|
|
Process.send_after(self(), :initialize_replay, 2000)
|
|
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
|
|
{: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
|
|
# 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
|
|
|
|
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 = to_float(speed)
|
|
{:noreply, assign(socket, replay_speed: speed_float)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_and_reload_markers", _params, socket) do
|
|
# Clear all markers on the client first
|
|
socket = push_event(socket, "clear_markers", %{})
|
|
|
|
# Filter visible packets to only include those within current bounds
|
|
filtered_packets =
|
|
socket.assigns.visible_packets
|
|
|> Enum.filter(fn {_callsign, packet} ->
|
|
within_bounds?(packet, socket.assigns.map_bounds) &&
|
|
packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold)
|
|
end)
|
|
|> Map.new()
|
|
|
|
# Convert filtered packets to packet data for the map
|
|
visible_packets_list =
|
|
filtered_packets
|
|
|> Enum.map(fn {_callsign, packet} -> build_packet_data(packet) end)
|
|
# Remove any nil packet data
|
|
|> Enum.filter(& &1)
|
|
|
|
# Update the state to only include the filtered packets
|
|
socket = assign(socket, visible_packets: filtered_packets)
|
|
|
|
# Add the visible markers back to the map
|
|
socket =
|
|
if Enum.any?(visible_packets_list) do
|
|
push_event(socket, "add_markers", %{markers: visible_packets_list})
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("map_ready", _params, socket) do
|
|
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
|
|
push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("marker_clicked", %{"id" => _id, "callsign" => _callsign, "lat" => _lat, "lng" => _lng}, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()}
|
|
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"]
|
|
}
|
|
|
|
# Validate bounds to prevent invalid coordinates
|
|
if map_bounds.north > 90 or map_bounds.south < -90 or
|
|
map_bounds.north <= map_bounds.south do
|
|
# Invalid bounds, skip update
|
|
{:noreply, socket}
|
|
else
|
|
# Cancel any pending bounds update timer
|
|
if socket.assigns[:bounds_update_timer] do
|
|
Process.cancel_timer(socket.assigns.bounds_update_timer)
|
|
end
|
|
|
|
# Set a debounced update timer to prevent excessive processing
|
|
timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 250)
|
|
|
|
socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds)
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@spec process_bounds_update(map(), Socket.t()) :: Socket.t()
|
|
defp process_bounds_update(map_bounds, socket) do
|
|
# Filter visible packets to only include those within the new bounds and time threshold
|
|
new_visible_packets =
|
|
socket.assigns.visible_packets
|
|
|> Enum.filter(fn {_callsign, packet} ->
|
|
within_bounds?(packet, map_bounds) &&
|
|
packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold)
|
|
end)
|
|
|> Map.new()
|
|
|
|
# Get packets that are no longer visible (to remove from map)
|
|
packets_to_remove =
|
|
socket.assigns.visible_packets
|
|
|> Enum.reject(fn {_callsign, packet} ->
|
|
within_bounds?(packet, map_bounds)
|
|
end)
|
|
|> Enum.map(fn {callsign, _packet} -> callsign end)
|
|
|
|
# Clear markers that are outside the new bounds
|
|
socket =
|
|
if Enum.any?(packets_to_remove) do
|
|
# Remove markers that are outside bounds
|
|
socket =
|
|
Enum.reduce(packets_to_remove, socket, fn callsign, acc_socket ->
|
|
push_event(acc_socket, "remove_marker", %{id: callsign})
|
|
end)
|
|
|
|
# Also send a general filter event to clean up any remaining out-of-bounds markers
|
|
push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds})
|
|
else
|
|
socket
|
|
end
|
|
|
|
# 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 significantly
|
|
socket =
|
|
assign(socket,
|
|
replay_packets: [],
|
|
replay_index: 0,
|
|
historical_packets: %{},
|
|
map_ready: true
|
|
)
|
|
|
|
socket
|
|
end
|
|
|
|
assign(socket,
|
|
map_bounds: map_bounds,
|
|
visible_packets: new_visible_packets,
|
|
bounds_update_timer: nil,
|
|
pending_bounds: nil
|
|
)
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(msg, socket) do
|
|
case msg do
|
|
{:process_bounds_update, map_bounds} ->
|
|
# Process the debounced bounds update
|
|
socket = process_bounds_update(map_bounds, socket)
|
|
{:noreply, socket}
|
|
|
|
{:delayed_zoom, %{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}} ->
|
|
# 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
|
|
push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
|
|
else
|
|
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)
|
|
|
|
# Get packets that need to be removed (old or outside bounds)
|
|
packets_to_remove =
|
|
socket.assigns.visible_packets
|
|
|> Enum.reject(fn {_key, packet} ->
|
|
packet_within_time_threshold?(packet, one_hour_ago) &&
|
|
within_bounds?(packet, socket.assigns.map_bounds)
|
|
end)
|
|
|> Enum.map(fn {callsign, _packet} -> callsign end)
|
|
|
|
# Filter out packets older than one hour and outside bounds
|
|
visible_packets =
|
|
socket.assigns.visible_packets
|
|
|> Enum.filter(fn {_key, packet} ->
|
|
packet_within_time_threshold?(packet, one_hour_ago) &&
|
|
within_bounds?(packet, socket.assigns.map_bounds)
|
|
end)
|
|
|> Map.new()
|
|
|
|
# Only update if there are actual changes to prevent unnecessary events
|
|
socket =
|
|
if map_size(visible_packets) == map_size(socket.assigns.visible_packets) do
|
|
# Just update the threshold without map changes
|
|
assign(socket, packet_age_threshold: one_hour_ago)
|
|
# Remove old and out-of-bounds markers from the map
|
|
|
|
# Explicitly remove markers for packets that are no longer valid
|
|
else
|
|
socket =
|
|
socket
|
|
|> push_event("refresh_markers", %{})
|
|
|> assign(
|
|
visible_packets: visible_packets,
|
|
packet_age_threshold: one_hour_ago
|
|
)
|
|
|
|
if Enum.any?(packets_to_remove) do
|
|
Enum.reduce(packets_to_remove, socket, fn callsign, acc_socket ->
|
|
push_event(acc_socket, "remove_marker", %{id: callsign})
|
|
end)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
# 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)
|
|
@spec packet_within_time_threshold?(map(), DateTime.t()) :: boolean()
|
|
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
|
|
@spec start_historical_replay(Socket.t()) :: Socket.t()
|
|
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
|
|
@spec fetch_historical_packets(list(), DateTime.t(), DateTime.t()) :: [struct()]
|
|
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
|
|
|
|
@spec has_position_data?(map() | struct()) :: boolean()
|
|
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
|
|
|
|
@spec within_bounds?(map() | struct(), map()) :: boolean()
|
|
defp within_bounds?(packet, bounds) do
|
|
{lat, lng} = get_coordinates(packet)
|
|
|
|
# Basic validation
|
|
if is_nil(lat) or is_nil(lng) do
|
|
false
|
|
else
|
|
# Check latitude bounds (straightforward)
|
|
lat_in_bounds = lat >= bounds.south && lat <= bounds.north
|
|
|
|
# Check longitude bounds (handle potential wrapping)
|
|
lng_in_bounds =
|
|
if bounds.west <= bounds.east do
|
|
# Normal case: bounds don't cross antimeridian
|
|
lng >= bounds.west && lng <= bounds.east
|
|
else
|
|
# Bounds cross antimeridian (e.g., west=170, east=-170)
|
|
lng >= bounds.west || lng <= bounds.east
|
|
end
|
|
|
|
lat_in_bounds && lng_in_bounds
|
|
end
|
|
end
|
|
|
|
@spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil}
|
|
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
|
|
{nil, nil}
|
|
end
|
|
|
|
%{latitude: lat, longitude: lon} ->
|
|
{lat, lon}
|
|
|
|
_ ->
|
|
{nil, nil}
|
|
end
|
|
end
|
|
|
|
@spec build_packet_data(map() | struct()) :: map() | nil
|
|
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
|
|
build_packet_map(packet, lat, lng, data_extended)
|
|
end
|
|
end
|
|
|
|
@spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map()
|
|
defp build_packet_map(packet, lat, lng, data_extended) do
|
|
{final_table_id, final_symbol_code} = get_validated_symbol(packet, data_extended)
|
|
callsign = generate_callsign(packet)
|
|
|
|
%{
|
|
"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 || %{}
|
|
}
|
|
end
|
|
|
|
@spec get_validated_symbol(map() | struct(), map() | nil) :: {String.t(), String.t()}
|
|
defp get_validated_symbol(packet, data_extended) do
|
|
symbol_table_id = get_symbol_table_id(packet, data_extended)
|
|
symbol_code = get_symbol_code(packet, data_extended)
|
|
|
|
if AprsSymbols.valid_symbol?(symbol_table_id, symbol_code) do
|
|
{symbol_table_id, symbol_code}
|
|
else
|
|
AprsSymbols.default_symbol()
|
|
end
|
|
end
|
|
|
|
@spec get_symbol_table_id(map() | struct(), map() | nil) :: String.t()
|
|
defp get_symbol_table_id(packet, data_extended) do
|
|
get_in(data_extended, ["symbol_table_id"]) || packet.symbol_table_id || "/"
|
|
end
|
|
|
|
@spec get_symbol_code(map() | struct(), map() | nil) :: String.t()
|
|
defp get_symbol_code(packet, data_extended) do
|
|
get_in(data_extended, ["symbol_code"]) || packet.symbol_code || ">"
|
|
end
|
|
|
|
@spec generate_callsign(map() | struct()) :: String.t()
|
|
defp generate_callsign(packet) do
|
|
if packet.ssid && packet.ssid != "" do
|
|
"#{packet.base_callsign}-#{packet.ssid}"
|
|
else
|
|
packet.base_callsign || ""
|
|
end
|
|
end
|
|
|
|
# Get IP location from external service
|
|
|
|
# Get location from IP using ip-api.com
|
|
@spec get_ip_location(String.t() | nil) :: {float(), float()} | nil
|
|
defp get_ip_location(nil), do: nil
|
|
|
|
@spec get_ip_location(String.t()) :: {float(), float()} | nil
|
|
defp get_ip_location(ip) do
|
|
url = "#{@ip_api_url}#{ip}"
|
|
|
|
# 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)
|
|
|
|
case Finch.request(request, @finch_name, receive_timeout: 10_000) do
|
|
{:ok, %{status: 200, body: body}} ->
|
|
case Jason.decode(body) do
|
|
{:ok, %{"status" => "success", "lat" => lat, "lon" => lng}}
|
|
when is_number(lat) and is_number(lng) ->
|
|
if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
|
|
# Force numeric values
|
|
lat_float = lat / 1.0
|
|
lng_float = lng / 1.0
|
|
send(self(), {:ip_location, %{lat: lat_float, lng: lng_float}})
|
|
else
|
|
send(self(), {:ip_location, @default_center})
|
|
end
|
|
|
|
{:ok, %{"status" => _status}} ->
|
|
send(self(), {:ip_location, @default_center})
|
|
|
|
{:ok, _data} ->
|
|
send(self(), {:ip_location, @default_center})
|
|
|
|
{:error, _decode_error} ->
|
|
send(self(), {:ip_location, @default_center})
|
|
end
|
|
|
|
{:ok, _response} ->
|
|
send(self(), {:ip_location, @default_center})
|
|
|
|
{:error, %{reason: :timeout}} ->
|
|
send(self(), {:ip_location, @default_center})
|
|
|
|
{:error, _error} ->
|
|
send(self(), {:ip_location, @default_center})
|
|
end
|
|
end
|
|
|
|
@spec build_data_extended(nil | MicE.t() | map()) :: nil | map()
|
|
defp build_data_extended(nil), do: nil
|
|
|
|
defp build_data_extended(%MicE{} = mic_e), do: build_mice_data_extended(mic_e)
|
|
defp build_data_extended(data_extended), do: build_map_data_extended(data_extended)
|
|
|
|
@spec build_mice_data_extended(MicE.t()) :: map()
|
|
defp build_mice_data_extended(mic_e) do
|
|
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
|
|
|
|
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
|
|
end
|
|
|
|
@spec build_map_data_extended(map()) :: map()
|
|
defp build_map_data_extended(data_extended) do
|
|
%{
|
|
"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
|
|
|
|
# Helper function to convert string or float to float
|
|
@spec to_float(number() | String.t()) :: float()
|
|
defp to_float(value) when is_float(value), do: value
|
|
defp to_float(value) when is_integer(value), do: value * 1.0
|
|
|
|
@spec to_float(String.t()) :: float()
|
|
defp to_float(value) when is_binary(value) do
|
|
case Float.parse(value) do
|
|
{float_val, _} -> float_val
|
|
:error -> 0.0
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def terminate(_reason, socket) do
|
|
# Clean up any pending bounds update timer
|
|
if socket.assigns[:bounds_update_timer] do
|
|
Process.cancel_timer(socket.assigns.bounds_update_timer)
|
|
end
|
|
|
|
# Clean up replay timer
|
|
if socket.assigns[:replay_timer_ref] do
|
|
Process.cancel_timer(socket.assigns.replay_timer_ref)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
end
|