1086 lines
31 KiB
Elixir
1086 lines
31 KiB
Elixir
defmodule AprsWeb.MapLive.CallsignView do
|
|
use AprsWeb, :live_view
|
|
|
|
alias Aprs.EncodingUtils
|
|
alias Aprs.Packets
|
|
alias AprsWeb.Endpoint
|
|
alias AprsWeb.MapLive.MapHelpers
|
|
|
|
@default_center %{lat: 39.0, lng: -98.0}
|
|
@default_zoom 4
|
|
@default_replay_speed 1.0
|
|
|
|
@impl true
|
|
def mount(%{"callsign" => callsign}, _session, socket) do
|
|
# Normalize callsign to uppercase
|
|
normalized_callsign = String.upcase(callsign)
|
|
|
|
# Calculate one hour ago for packet age filtering
|
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
|
|
|
socket =
|
|
assign(socket,
|
|
callsign: normalized_callsign,
|
|
packets: [],
|
|
page_title: "APRS Map - #{normalized_callsign}",
|
|
# Track visible packets by callsign
|
|
visible_packets: %{},
|
|
# Default bounds - will be updated based on packet locations
|
|
map_bounds: %{
|
|
north: 49.0,
|
|
south: 24.0,
|
|
east: -66.0,
|
|
west: -125.0
|
|
},
|
|
map_center: @default_center,
|
|
map_zoom: @default_zoom,
|
|
default_center: @default_center,
|
|
default_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,
|
|
# Last known position for auto-zoom
|
|
last_known_position: nil,
|
|
# Flag to track if packets have been loaded
|
|
packets_loaded: false,
|
|
# Latest symbol table ID and code
|
|
latest_symbol_table_id: "/",
|
|
latest_symbol_code: ">"
|
|
)
|
|
|
|
if connected?(socket) do
|
|
Endpoint.subscribe("aprs_messages")
|
|
|
|
# Don't load packets here - wait for map_ready event
|
|
# socket = load_callsign_packets(socket, normalized_callsign)
|
|
|
|
# Schedule regular cleanup of old packets from the map
|
|
Process.send_after(self(), :cleanup_old_packets, 60_000)
|
|
# Auto-start replay after a short delay
|
|
Process.send_after(self(), :auto_start_replay, 2000)
|
|
|
|
{:ok, socket}
|
|
else
|
|
{:ok, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do
|
|
handle_bounds_update(bounds, socket)
|
|
end
|
|
|
|
def handle_event("update_bounds", %{"bounds" => bounds}, socket) do
|
|
handle_bounds_update(bounds, socket)
|
|
end
|
|
|
|
def handle_event("locate_me", _params, socket) do
|
|
socket = push_event(socket, "request_geolocation", %{})
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
|
|
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
|
|
|
|
def handle_event("pause_replay", _params, socket) do
|
|
if socket.assigns.replay_timer_ref do
|
|
Process.cancel_timer(socket.assigns.replay_timer_ref)
|
|
end
|
|
|
|
socket =
|
|
assign(socket,
|
|
replay_paused: not socket.assigns.replay_paused,
|
|
replay_timer_ref: nil
|
|
)
|
|
|
|
# Resume replay if unpausing
|
|
socket =
|
|
if socket.assigns.replay_paused do
|
|
socket
|
|
else
|
|
Process.send_after(self(), :replay_next_packet, 100)
|
|
socket
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
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("marker_clicked", _params, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("map_ready", _params, socket) do
|
|
socket = assign(socket, map_ready: true)
|
|
|
|
# Load callsign packets now that the map is ready
|
|
socket =
|
|
if socket.assigns.packets_loaded do
|
|
socket
|
|
else
|
|
socket
|
|
|> load_callsign_packets(socket.assigns.callsign)
|
|
|> assign(packets_loaded: true)
|
|
end
|
|
|
|
# Auto-start replay if it hasn't been started yet
|
|
socket =
|
|
if socket.assigns.replay_started do
|
|
socket
|
|
else
|
|
socket = start_historical_replay(socket)
|
|
assign(socket, replay_started: true, replay_active: true)
|
|
end
|
|
|
|
# If we have a pending geolocation, zoom to it after a delay
|
|
socket =
|
|
if socket.assigns.pending_geolocation do
|
|
location = socket.assigns.pending_geolocation
|
|
Process.send_after(self(), {:zoom_to_location, location.lat, location.lng, 12}, 1500)
|
|
socket
|
|
else
|
|
# If we have a last known position, zoom to it after a delay
|
|
if socket.assigns.last_known_position do
|
|
pos = socket.assigns.last_known_position
|
|
Process.send_after(self(), {:zoom_to_location, pos.lat, pos.lng, 12}, 1500)
|
|
socket
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp handle_bounds_update(bounds, socket) do
|
|
# Convert string keys to atom keys and parse values
|
|
normalized_bounds = %{
|
|
north: to_float(bounds["north"]),
|
|
south: to_float(bounds["south"]),
|
|
east: to_float(bounds["east"]),
|
|
west: to_float(bounds["west"])
|
|
}
|
|
|
|
# Remove out-of-bounds visible packets
|
|
new_visible_packets =
|
|
socket.assigns.visible_packets
|
|
|> Enum.filter(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end)
|
|
|> Map.new()
|
|
|
|
markers_to_remove =
|
|
socket.assigns.visible_packets
|
|
|> Enum.reject(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end)
|
|
|> Enum.map(fn {k, _} -> k end)
|
|
|
|
socket =
|
|
if markers_to_remove == [] do
|
|
socket
|
|
else
|
|
Enum.reduce(markers_to_remove, socket, fn k, acc ->
|
|
push_event(acc, "remove_marker", %{id: k})
|
|
end)
|
|
end
|
|
|
|
socket = assign(socket, map_bounds: normalized_bounds, visible_packets: new_visible_packets)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
# Helper function to convert string or float to float
|
|
defp to_float(value) when is_float(value), do: value
|
|
defp to_float(value) when is_integer(value), do: value * 1.0
|
|
|
|
defp to_float(value) when is_binary(value) do
|
|
case Float.parse(value) do
|
|
{float_val, _} -> float_val
|
|
:error -> raise ArgumentError, "Invalid float string: #{value}"
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:zoom_to_location, lat, lng, zoom}, socket) do
|
|
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: zoom})
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info(msg, socket) do
|
|
case msg do
|
|
{:delayed_zoom, %{lat: lat, lng: lng}} ->
|
|
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
|
|
{:noreply, socket}
|
|
|
|
:auto_start_replay ->
|
|
# Auto-start replay if it hasn't been started yet and map is ready
|
|
if not socket.assigns.replay_started and socket.assigns.map_ready do
|
|
socket = start_historical_replay(socket)
|
|
{:noreply, assign(socket, replay_started: true, replay_active: true)}
|
|
else
|
|
# If map isn't ready yet, try again in a bit
|
|
if not socket.assigns.map_ready do
|
|
Process.send_after(self(), :auto_start_replay, 1000)
|
|
end
|
|
|
|
{: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)
|
|
|
|
# Stream packets from PubSub only if within bounds
|
|
%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} ->
|
|
sanitized_packet = EncodingUtils.sanitize_packet(payload)
|
|
sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now())
|
|
{lat, lng, _} = MapHelpers.get_coordinates(sanitized_packet)
|
|
|
|
callsign_key =
|
|
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
|
|
|
|
if MapHelpers.has_position_data?(sanitized_packet) and
|
|
packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and
|
|
MapHelpers.within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and
|
|
packet_within_time_threshold?(
|
|
sanitized_packet,
|
|
socket.assigns.packet_age_threshold
|
|
) do
|
|
packet_data = build_packet_data(sanitized_packet)
|
|
|
|
# Remove any previous marker for this callsign
|
|
socket =
|
|
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
|
|
push_event(socket, "remove_marker", %{id: callsign_key})
|
|
else
|
|
socket
|
|
end
|
|
|
|
visible_packets = %{callsign_key => sanitized_packet}
|
|
|
|
last_known_position =
|
|
if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position
|
|
|
|
socket =
|
|
socket
|
|
|> push_event("new_packet", packet_data)
|
|
|> assign(
|
|
visible_packets: visible_packets,
|
|
last_known_position: last_known_position
|
|
)
|
|
|
|
{:noreply, socket}
|
|
else
|
|
# Remove marker if it exists and is now out of bounds or expired
|
|
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
|
|
socket = push_event(socket, "remove_marker", %{id: callsign_key})
|
|
visible_packets = %{}
|
|
{:noreply, assign(socket, visible_packets: visible_packets)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
_ ->
|
|
# Ignore packets that don't match our callsign or don't have position data
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
defp handle_replay_next_packet(socket) do
|
|
if should_continue_replay?(socket) do
|
|
packet = Enum.at(socket.assigns.replay_packets, socket.assigns.replay_index)
|
|
handle_replay_packet(packet, socket)
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
defp should_continue_replay?(socket) do
|
|
socket.assigns.replay_active and
|
|
not socket.assigns.replay_paused and
|
|
socket.assigns.replay_index < length(socket.assigns.replay_packets)
|
|
end
|
|
|
|
defp handle_replay_packet(nil, socket) do
|
|
socket =
|
|
assign(socket,
|
|
replay_active: false,
|
|
replay_paused: false,
|
|
replay_timer_ref: nil,
|
|
replay_index: 0
|
|
)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp handle_replay_packet(packet, socket) do
|
|
case build_packet_data(packet) do
|
|
nil -> handle_invalid_replay_packet(socket)
|
|
packet_data -> handle_valid_replay_packet(packet, packet_data, socket)
|
|
end
|
|
end
|
|
|
|
defp handle_invalid_replay_packet(socket) do
|
|
timer_ref = Process.send_after(self(), :replay_next_packet, 10)
|
|
|
|
socket =
|
|
assign(socket,
|
|
replay_index: socket.assigns.replay_index + 1,
|
|
replay_timer_ref: timer_ref
|
|
)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp handle_valid_replay_packet(packet, packet_data, socket) do
|
|
historical_packets = Map.put(socket.assigns.historical_packets, packet_data["id"], packet)
|
|
socket = push_event(socket, "historical_packet", Map.put(packet_data, :historical, true))
|
|
delay = trunc(1000 / socket.assigns.replay_speed)
|
|
timer_ref = Process.send_after(self(), :replay_next_packet, delay)
|
|
|
|
socket =
|
|
assign(socket,
|
|
replay_index: socket.assigns.replay_index + 1,
|
|
replay_timer_ref: timer_ref,
|
|
historical_packets: historical_packets
|
|
)
|
|
|
|
{:noreply, socket}
|
|
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: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: 0 !important;
|
|
width: 100vw !important;
|
|
height: 100vh !important;
|
|
z-index: 1 !important;
|
|
min-width: 100vw !important;
|
|
min-height: 100vh !important;
|
|
max-width: 100vw !important;
|
|
max-height: 100vh !important;
|
|
}
|
|
|
|
/* Ensure the map container has a defined height */
|
|
.phx-main {
|
|
position: relative;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Fix for potential layout issues */
|
|
body, html {
|
|
height: 100%;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.callsign-header {
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 10px;
|
|
z-index: 1000;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border: 2px solid rgba(0,0,0,0.2);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
color: #333;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
min-width: 200px;
|
|
}
|
|
|
|
.callsign-title {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.aprs-symbol-info {
|
|
font-size: 11px;
|
|
color: #666;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.nav-links {
|
|
display: flex;
|
|
gap: 12px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.nav-link {
|
|
color: #007cba;
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.nav-link:hover {
|
|
background-color: rgba(0, 124, 186, 0.1);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.locate-button {
|
|
position: absolute;
|
|
left: 10px;
|
|
top: 120px;
|
|
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;
|
|
}
|
|
|
|
.replay-controls {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
z-index: 1000;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border: 2px solid rgba(0,0,0,0.2);
|
|
border-radius: 6px;
|
|
padding: 8px;
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
font-size: 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
}
|
|
|
|
.replay-button {
|
|
background: #007cba;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.replay-button:hover {
|
|
background: #005a87;
|
|
}
|
|
|
|
.replay-button.active {
|
|
background: #dc3545;
|
|
}
|
|
|
|
.speed-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.speed-control input {
|
|
width: 60px;
|
|
padding: 2px 4px;
|
|
border: 1px solid #ccc;
|
|
border-radius: 2px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.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;
|
|
margin-bottom: 4px;
|
|
color: #333;
|
|
}
|
|
|
|
.aprs-comment {
|
|
font-size: 11px;
|
|
color: #444;
|
|
margin: 4px 0;
|
|
font-style: italic;
|
|
}
|
|
|
|
.aprs-coords {
|
|
font-size: 10px;
|
|
color: #888;
|
|
margin-top: 4px;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.aprs-time {
|
|
font-size: 10px;
|
|
color: #999;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.empty-state {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 1000;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border: 2px solid rgba(0,0,0,0.1);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
text-align: center;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
color: #666;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
max-width: 300px;
|
|
}
|
|
|
|
.empty-state h3 {
|
|
margin: 0 0 8px 0;
|
|
color: #333;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.empty-state p {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
line-height: 1.4;
|
|
}
|
|
</style>
|
|
|
|
<div style="position: relative; width: 100vw; height: 100vh;">
|
|
<div class="callsign-header">
|
|
<div class="callsign-title">{@callsign}</div>
|
|
|
|
<div class="nav-links">
|
|
<a href="/" class="nav-link">← Back to Map</a>
|
|
<a href="/packets" class="nav-link">All Packets</a>
|
|
<a href={"/packets/#{String.downcase(@callsign)}"} class="nav-link">{@callsign} Packets</a>
|
|
</div>
|
|
</div>
|
|
|
|
<%= if map_size(@visible_packets) == 0 and not @replay_active do %>
|
|
<div class="empty-state">
|
|
<h3>Loading Historical Data</h3>
|
|
<p>
|
|
Loading packet history for {@callsign}...
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div
|
|
id="aprs-map"
|
|
phx-hook="APRSMap"
|
|
phx-update="ignore"
|
|
data-center={Jason.encode!(@map_center || @default_center)}
|
|
data-zoom={@map_zoom || @default_zoom}
|
|
>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp handle_cleanup_old_packets(socket) do
|
|
# Schedule next cleanup
|
|
Process.send_after(self(), :cleanup_old_packets, 60_000)
|
|
|
|
# Update packet age threshold
|
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
|
socket = assign(socket, packet_age_threshold: one_hour_ago)
|
|
|
|
# Remove expired packets from visible_packets
|
|
expired_keys =
|
|
socket.assigns.visible_packets
|
|
|> Enum.filter(fn {_key, packet} ->
|
|
not packet_within_time_threshold?(packet, one_hour_ago)
|
|
end)
|
|
|> Enum.map(fn {key, _} -> key end)
|
|
|
|
# Only update the client if there are expired markers
|
|
socket =
|
|
if expired_keys == [] do
|
|
socket
|
|
else
|
|
Enum.reduce(expired_keys, socket, fn key, acc ->
|
|
push_event(acc, "remove_marker", %{id: key})
|
|
end)
|
|
end
|
|
|
|
# Use Map.drop/2 for better performance
|
|
updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys)
|
|
socket = assign(socket, visible_packets: updated_visible_packets)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp packet_within_time_threshold?(packet, threshold) do
|
|
received_at = Map.get(packet, :received_at, DateTime.utc_now())
|
|
|
|
case received_at do
|
|
%DateTime{} -> DateTime.compare(received_at, threshold) != :lt
|
|
# If no timestamp, assume it's current
|
|
_ -> true
|
|
end
|
|
end
|
|
|
|
defp start_historical_replay(socket) do
|
|
# Fetch historical packets for the specific callsign from the last hour
|
|
now = DateTime.utc_now()
|
|
one_hour_ago = DateTime.add(now, -3600, :second)
|
|
|
|
packets =
|
|
fetch_historical_packets_for_callsign(
|
|
socket.assigns.callsign,
|
|
one_hour_ago,
|
|
now
|
|
)
|
|
|
|
if Enum.empty?(packets) do
|
|
# No historical packets found
|
|
socket
|
|
else
|
|
# Clear any previous historical packets from the map
|
|
socket = push_event(socket, "clear_historical_packets", %{})
|
|
|
|
# Sort packets by inserted_at to identify the most recent
|
|
# Convert NaiveDateTime to DateTime if needed for proper comparison
|
|
sorted_packets =
|
|
Enum.sort_by(
|
|
packets,
|
|
fn packet ->
|
|
case packet.inserted_at do
|
|
%NaiveDateTime{} = naive_dt ->
|
|
DateTime.from_naive!(naive_dt, "Etc/UTC")
|
|
|
|
%DateTime{} = dt ->
|
|
dt
|
|
|
|
_other ->
|
|
DateTime.utc_now()
|
|
end
|
|
end,
|
|
{:desc, DateTime}
|
|
)
|
|
|
|
# Filter out packets with unchanged positions (only keep if lat/lon changed)
|
|
unique_position_packets = filter_unique_positions(sorted_packets)
|
|
|
|
# Build packet data for all positions, marking which is the most recent
|
|
packet_data_list =
|
|
unique_position_packets
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {packet, index} ->
|
|
case build_packet_data(packet) do
|
|
nil ->
|
|
nil
|
|
|
|
packet_data ->
|
|
# Generate a unique ID for this historical packet
|
|
packet_id =
|
|
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
|
|
|
|
is_most_recent = index == 0
|
|
callsign = socket.assigns.callsign
|
|
|
|
packet_data
|
|
|> Map.put("id", packet_id)
|
|
|> Map.put("is_historical", true)
|
|
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|
|
|> Map.put("callsign_group", callsign)
|
|
|> Map.put(
|
|
"timestamp",
|
|
case packet.inserted_at do
|
|
%NaiveDateTime{} = naive_dt ->
|
|
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
|
|
|
|
%DateTime{} = dt ->
|
|
DateTime.to_unix(dt, :millisecond)
|
|
|
|
_other ->
|
|
DateTime.to_unix(DateTime.utc_now(), :millisecond)
|
|
end
|
|
)
|
|
end
|
|
end)
|
|
|> Enum.filter(& &1)
|
|
|
|
# Send all historical packets at once
|
|
socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list})
|
|
|
|
# Store historical packets in assigns for reference
|
|
historical_packets_map =
|
|
packet_data_list
|
|
|> Enum.zip(unique_position_packets)
|
|
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
|
|
Map.put(acc, packet_data["id"], packet)
|
|
end)
|
|
|
|
assign(socket,
|
|
historical_packets: historical_packets_map,
|
|
replay_packets: [],
|
|
replay_index: 0,
|
|
replay_start_time: one_hour_ago,
|
|
replay_end_time: now
|
|
)
|
|
end
|
|
end
|
|
|
|
defp fetch_historical_packets_for_callsign(callsign, start_time, end_time) do
|
|
Packets.get_packets_for_replay(%{
|
|
callsign: callsign,
|
|
start_time: start_time,
|
|
end_time: end_time,
|
|
limit: 1000
|
|
})
|
|
end
|
|
|
|
# Filter packets to only include those with unique positions (lat/lon changed)
|
|
@spec filter_unique_positions([struct()]) :: [struct()]
|
|
defp filter_unique_positions(packets) do
|
|
packets
|
|
|> Enum.reduce([], fn packet, acc ->
|
|
{lat, lng, _} = MapHelpers.get_coordinates(packet)
|
|
|
|
if lat && lng do
|
|
# Check if this position is different from the last position
|
|
case acc do
|
|
[] ->
|
|
# First packet, always include
|
|
[packet | acc]
|
|
|
|
[last_packet | _] ->
|
|
if position_changed?(packet, last_packet) do
|
|
[packet | acc]
|
|
else
|
|
acc
|
|
end
|
|
end
|
|
else
|
|
acc
|
|
end
|
|
end)
|
|
|> Enum.reverse()
|
|
end
|
|
|
|
# Check if position changed significantly between two packets (more than ~1 meter)
|
|
@spec position_changed?(struct(), struct()) :: boolean()
|
|
defp position_changed?(packet1, packet2) do
|
|
{lat1, lng1, _} = MapHelpers.get_coordinates(packet1)
|
|
{lat2, lng2, _} = MapHelpers.get_coordinates(packet2)
|
|
|
|
abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001
|
|
end
|
|
|
|
defp build_packet_data(packet) do
|
|
{lat, lng, _} = MapHelpers.get_coordinates(packet)
|
|
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
|
|
|
|
if lat != nil and lng != nil and callsign != "" and callsign != nil do
|
|
build_packet_map(packet, lat, lng, packet.data_extended)
|
|
end
|
|
end
|
|
|
|
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
|
|
|
|
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>
|
|
#{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-time\">#{timestamp}</div>
|
|
</div>
|
|
"""
|
|
|
|
%{
|
|
"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", "")),
|
|
"comment" => comment,
|
|
"data_extended" => convert_tuples_to_strings(data_extended || %{}),
|
|
"symbol_table_id" => symbol_table_id,
|
|
"symbol_code" => symbol_code,
|
|
"symbol_description" => symbol_description,
|
|
"timestamp" => timestamp,
|
|
"popup" => popup
|
|
}
|
|
end
|
|
|
|
defp packet_matches_callsign?(packet, target_callsign) do
|
|
normalized_packet = normalize_packet_callsign(packet)
|
|
normalized_target = normalize_target_callsign(target_callsign)
|
|
exact_or_base_match?(normalized_packet, normalized_target, packet)
|
|
end
|
|
|
|
defp normalize_packet_callsign(packet) do
|
|
base_callsign = packet[:base_callsign] || packet.base_callsign || ""
|
|
ssid = packet[:ssid] || packet.ssid
|
|
|
|
case_result =
|
|
case ssid do
|
|
nil -> base_callsign
|
|
"" -> base_callsign
|
|
"0" -> base_callsign
|
|
_ -> "#{base_callsign}-#{ssid}"
|
|
end
|
|
|
|
case_result
|
|
|> String.upcase()
|
|
|> String.trim()
|
|
end
|
|
|
|
defp normalize_target_callsign(target_callsign) do
|
|
target_callsign
|
|
|> String.upcase()
|
|
|> String.trim()
|
|
end
|
|
|
|
defp exact_or_base_match?(normalized_packet, normalized_target, packet) do
|
|
cond do
|
|
normalized_packet == normalized_target ->
|
|
true
|
|
|
|
not String.contains?(normalized_target, "-") ->
|
|
base_callsign = packet[:base_callsign] || packet.base_callsign || ""
|
|
String.upcase(String.trim(base_callsign)) == normalized_target
|
|
|
|
true ->
|
|
false
|
|
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 =
|
|
%{callsign: callsign}
|
|
|> Packets.get_recent_packets()
|
|
|> Enum.filter(&MapHelpers.has_position_data?/1)
|
|
|> Enum.sort_by(& &1.received_at, {:desc, DateTime})
|
|
|> List.first()
|
|
|
|
last_known_position =
|
|
case latest_packet do
|
|
nil ->
|
|
nil
|
|
|
|
packet ->
|
|
{lat, lng, _} = MapHelpers.get_coordinates(packet)
|
|
if lat && lng, do: %{lat: lat, lng: lng}
|
|
end
|
|
|
|
latest_symbol_table_id =
|
|
case latest_packet do
|
|
%{data_extended: %{symbol_table_id: id}} when is_binary(id) -> id
|
|
_ -> "/"
|
|
end
|
|
|
|
latest_symbol_code =
|
|
case latest_packet do
|
|
%{data_extended: %{symbol_code: code}} when is_binary(code) -> code
|
|
_ -> ">"
|
|
end
|
|
|
|
# Build visible_packets map with only the latest packet
|
|
visible_packets =
|
|
case latest_packet do
|
|
nil ->
|
|
%{}
|
|
|
|
packet ->
|
|
callsign_key =
|
|
"#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}"
|
|
|
|
%{callsign_key => packet}
|
|
end
|
|
|
|
# Send only the latest marker to the map if it exists
|
|
socket =
|
|
case latest_packet do
|
|
nil ->
|
|
socket
|
|
|
|
packet ->
|
|
packet_data = build_packet_data(packet)
|
|
|
|
if packet_data,
|
|
do: push_event(socket, "add_markers", %{markers: [packet_data]}),
|
|
else: socket
|
|
end
|
|
|
|
assign(socket,
|
|
last_known_position: last_known_position,
|
|
visible_packets: visible_packets,
|
|
latest_symbol_table_id: latest_symbol_table_id,
|
|
latest_symbol_code: latest_symbol_code
|
|
)
|
|
end
|
|
end
|