defmodule AprsWeb.MapLive.Index do
@moduledoc """
LiveView for displaying real-time APRS packets on a map
"""
use AprsWeb, :live_view
alias AprsWeb.Endpoint
alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.MapLive.PacketUtils
alias Phoenix.LiveView.Socket
require Logger
@default_center %{lat: 39.8283, lng: -98.5795}
@default_zoom 5
@finch_name Aprs.Finch
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
# Subscribe to packet updates
Phoenix.PubSub.subscribe(Aprs.PubSub, "packets")
Phoenix.PubSub.subscribe(Aprs.PubSub, "bad_packets")
# Schedule periodic cleanup of old packets
Process.send_after(self(), :cleanup_old_packets, 60_000)
end
# Get deployment timestamp from environment variable or use current time as fallback
deployed_at =
case System.get_env("DEPLOYED_AT") do
nil ->
# Fallback to current time if not set
DateTime.utc_now()
timestamp when is_binary(timestamp) ->
case DateTime.from_iso8601(timestamp) do
{:ok, datetime, _} -> datetime
_ -> DateTime.utc_now()
end
end
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
socket = assign_defaults(socket, one_hour_ago)
socket = assign(socket, packet_buffer: [], buffer_timer: nil)
socket = assign(socket, all_packets: %{})
if connected?(socket) do
Endpoint.subscribe("aprs_messages")
Phoenix.PubSub.subscribe(Aprs.PubSub, "postgres:aprs_packets")
maybe_start_geolocation(socket)
end
{:ok,
assign(socket,
map_ready: false,
map_bounds: nil,
map_center: %{lat: 39.8283, lng: -98.5795},
map_zoom: 4,
visible_packets: %{},
historical_packets: %{},
overlay_callsign: "",
trail_duration: "1",
historical_hours: "1",
packet_age_threshold: 3600,
slideover_open: true,
replay_active: false,
replay_start_time: nil,
replay_end_time: nil,
replay_current_time: nil,
replay_speed: 1,
deployed_at: deployed_at
)}
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,
historical_packets: %{},
packet_age_threshold: one_hour_ago,
map_ready: false,
historical_loaded: false,
pending_geolocation: nil,
bounds_update_timer: nil,
pending_bounds: nil,
initial_bounds_loaded: false,
# Overlay controls
overlay_callsign: "",
trail_duration: "1",
historical_hours: "1",
# Slideover state - will be set based on screen size
slideover_open: true
)
end
@spec maybe_start_geolocation(Socket.t()) :: Socket.t()
defp maybe_start_geolocation(socket) do
if geolocation_enabled?() do
ip_for_geolocation =
if Application.get_env(:aprs, AprsWeb.Endpoint)[:code_reloader] do
# For testing geolocation in dev environment, use a public IP address.
# This will be geolocated to Mountain View, CA.
"8.8.8.8"
else
extract_ip(socket)
end
if valid_ip_for_geolocation?(ip_for_geolocation) do
start_geolocation_task(ip_for_geolocation)
end
end
socket
end
defp geolocation_enabled? do
Application.get_env(:aprs, :disable_aprs_connection, false) != true
end
defp extract_ip(socket) do
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
end
defp valid_ip_for_geolocation?(ip) do
ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1")
end
defp start_geolocation_task(ip) do
Task.start(fn ->
try do
get_ip_location(ip)
rescue
_error ->
send(self(), {:ip_location, @default_center})
end
end)
end
@impl true
def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do
Logger.debug("handle_event bounds_changed: #{inspect(bounds)} vs current #{inspect(socket.assigns.map_bounds)}")
handle_bounds_update(bounds, socket)
end
@impl true
def handle_event("update_bounds", %{"bounds" => bounds}, socket) do
Logger.debug("handle_event update_bounds: #{inspect(bounds)} vs current #{inspect(socket.assigns.map_bounds)}")
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("clear_and_reload_markers", _params, socket) do
# Only filter the current visible_packets, do not re-query the database
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()
visible_packets_list =
filtered_packets
|> Enum.map(fn {_callsign, packet} -> build_packet_data(packet) end)
|> Enum.filter(& &1)
socket = assign(socket, visible_packets: filtered_packets)
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)
# Start historical replay
Process.send_after(self(), :initialize_replay, 500)
# 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", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("update_callsign", %{"callsign" => callsign}, socket) do
{:noreply, assign(socket, overlay_callsign: callsign)}
end
@impl true
def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket) do
# Convert duration string to hours and calculate new threshold
hours = String.to_integer(duration)
new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold)
# Trigger cleanup to remove packets that are now outside the new duration
send(self(), :cleanup_old_packets)
{:noreply, socket}
end
@impl true
def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket) do
socket = assign(socket, historical_hours: hours)
# Trigger a reload of historical packets with the new time range
if socket.assigns.map_ready do
send(self(), :reload_historical_packets)
end
{:noreply, socket}
end
@impl true
def handle_event("search_callsign", %{"callsign" => callsign}, socket) do
trimmed_callsign = callsign |> String.trim() |> String.upcase()
if trimmed_callsign == "" do
{:noreply, socket}
else
# Navigate to the callsign-specific route
{:noreply, push_navigate(socket, to: "/#{trimmed_callsign}")}
end
end
@impl true
def handle_event("toggle_slideover", _params, socket) do
{:noreply, assign(socket, slideover_open: !socket.assigns.slideover_open)}
end
@impl true
def handle_event("set_slideover_state", %{"open" => open}, socket) do
{:noreply, assign(socket, slideover_open: open)}
end
@impl true
def handle_event("geolocation_error", %{"error" => _error}, socket) do
# Handle geolocation errors gracefully - just continue without geolocation
{:noreply, socket}
end
@impl true
def handle_event("request_geolocation", _params, socket) do
# This event is handled by the JavaScript hook
{: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"]
}
Logger.debug("handle_bounds_update: new #{inspect(map_bounds)} vs current #{inspect(socket.assigns.map_bounds)}")
# 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
# Only schedule a bounds update if the bounds have actually changed (with rounding)
if compare_bounds(map_bounds, socket.assigns.map_bounds) do
{:noreply, socket}
# Cancel any pending bounds update timer
else
if socket.assigns[:bounds_update_timer] do
Process.cancel_timer(socket.assigns.bounds_update_timer)
end
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
end
@spec process_bounds_update(map(), Socket.t()) :: Socket.t()
defp process_bounds_update(map_bounds, socket) do
Logger.debug("process_bounds_update: Loading historical packets for bounds #{inspect(map_bounds)}")
# Remove out-of-bounds packets and markers immediately
new_visible_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_k, packet} -> within_bounds?(packet, map_bounds) end)
|> Map.new()
packets_to_remove =
socket.assigns.visible_packets
|> Enum.reject(fn {_k, packet} -> within_bounds?(packet, map_bounds) end)
|> Enum.map(fn {k, _} -> k end)
# Remove markers for out-of-bounds packets
socket =
if packets_to_remove == [] do
socket
else
Enum.reduce(packets_to_remove, socket, fn k, acc ->
push_event(acc, "remove_marker", %{id: k})
end)
end
# Clear existing historical packets
socket = push_event(socket, "clear_historical_packets", %{})
# Load historical packets for the new bounds
socket = load_historical_packets_for_bounds(socket, map_bounds)
# Update map bounds and visible packets
assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets)
end
@impl true
def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket)
def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket), do: handle_info_delayed_zoom(lat, lng, socket)
def handle_info({:ip_location, %{lat: lat, lng: lng}}, socket), do: handle_info_ip_location(lat, lng, socket)
def handle_info(:initialize_replay, socket), do: handle_info_initialize_replay(socket)
def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket)
def handle_info(:reload_historical_packets, socket), do: handle_reload_historical_packets(socket)
def handle_info({:postgres_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket)
def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket),
do: handle_info({:postgres_packet, packet}, socket)
# Private handler functions for each message type
defp handle_info_process_bounds_update(map_bounds, socket) do
if !socket.assigns.initial_bounds_loaded or
not compare_bounds(map_bounds, socket.assigns.map_bounds) do
socket = process_bounds_update(map_bounds, socket)
socket = assign(socket, initial_bounds_loaded: true)
{:noreply, socket}
else
{:noreply, socket}
end
end
defp handle_info_delayed_zoom(lat, lng, socket) do
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
{:noreply, socket}
end
defp handle_info_ip_location(lat, 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
# Schedule a delayed zoom to give the user a moment to see the map
Process.send_after(self(), {:delayed_zoom, %{lat: lat_float, lng: lng_float}}, 500)
# We can still optimistically set the center and zoom
socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
{:noreply, socket}
end
defp handle_info_initialize_replay(socket) do
if not socket.assigns.historical_loaded and socket.assigns.map_ready do
socket = start_historical_replay(socket)
{:noreply, socket}
else
{:noreply, socket}
end
end
defp handle_info_postgres_packet(packet, socket) do
{lat, lon, _data_extended} = MapHelpers.get_coordinates(packet)
callsign_key = get_callsign_key(packet)
# Update all_packets
all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet)
socket = assign(socket, all_packets: all_packets)
# 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}
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 =
if Map.has_key?(packet, "id"),
do: to_string(packet["id"]),
else: System.unique_integer([:positive])
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
marker_data = build_packet_data(packet)
socket =
if marker_data do
push_event(socket, "add_markers", %{markers: [marker_data]})
else
socket
end
{:noreply, assign(socket, visible_packets: new_visible_packets)}
end
# Handle replaying the next historical packet
@impl true
def render(assigns) do
~H"""
"""
end
# Clean up expired markers from visible_packets and client, but do not re-query the DB
defp handle_cleanup_old_packets(socket) do
# Schedule next cleanup
Process.send_after(self(), :cleanup_old_packets, 60_000)
# Use the current packet_age_threshold instead of hardcoded one hour
threshold = socket.assigns.packet_age_threshold
# Remove expired packets from visible_packets
expired_keys =
socket.assigns.visible_packets
|> Enum.filter(fn {_key, packet} ->
not packet_within_time_threshold?(packet, threshold)
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)
{:noreply, assign(socket, visible_packets: updated_visible_packets)}
end
defp handle_reload_historical_packets(socket) do
if socket.assigns.map_ready and socket.assigns.map_bounds do
# Clear existing historical packets
socket = assign(socket, historical_packets: %{})
# Clear all markers on the client
socket = push_event(socket, "clear_all_markers", %{})
# Load historical packets with new time range
socket = load_historical_packets_for_bounds(socket, socket.assigns.map_bounds)
{:noreply, socket}
else
{:noreply, socket}
end
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
# Only fetch historical packets if map is ready and bounds are available
if socket.assigns.map_ready and socket.assigns.map_bounds do
# Get time range for historical data
now = DateTime.utc_now()
# Use the user's selected historical hours setting
historical_hours = String.to_integer(socket.assigns.historical_hours)
start_time = DateTime.add(now, -historical_hours * 3600, :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, start_time, now)
if Enum.empty?(historical_packets) do
# No historical packets found - silently continue
socket
else
# Clear any previous historical packets from the map
socket = push_event(socket, "clear_historical_packets", %{})
# Group packets by callsign-ssid and process all positions
packet_data_list =
historical_packets
|> Enum.group_by(&generate_callsign/1)
|> Enum.flat_map(fn {callsign, 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)
# Process all packets with unique positions, marking which is the most recent
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
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)
end)
# 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(historical_packets)
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
Map.put(acc, packet_data["id"], packet)
end)
assign(socket,
historical_packets: historical_packets_map,
historical_loaded: true
)
end
else
socket
end
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, lon, _} = MapHelpers.get_coordinates(packet)
if lat && lon do
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
# 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
effective_start_time = 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_module = Application.get_env(:aprs, :packets_module, Aprs.Packets)
packets = packets_module.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 load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t()
defp load_historical_packets_for_bounds(socket, map_bounds) do
# Get time range for historical data
now = DateTime.utc_now()
# Use the user's selected historical hours setting
historical_hours = String.to_integer(socket.assigns.historical_hours)
start_time = DateTime.add(now, -historical_hours * 3600, :second)
# Convert map bounds to the format expected by the database query
bounds = [
map_bounds.west,
map_bounds.south,
map_bounds.east,
map_bounds.north
]
# Fetch historical packets with position data within the current map bounds
historical_packets = fetch_historical_packets(bounds, start_time, now)
if Enum.empty?(historical_packets) do
# No historical packets found
socket
else
# Group packets by callsign-ssid and process all positions
packet_data_list =
historical_packets
|> Enum.group_by(&generate_callsign/1)
|> Enum.flat_map(fn {callsign, packets} ->
# Sort packets by inserted_at to identify the most recent
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
unique_position_packets = filter_unique_positions(sorted_packets)
# Process all packets with unique positions, marking which is the most recent
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
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)
end)
# 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(historical_packets)
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
Map.put(acc, packet_data["id"], packet)
end)
assign(socket, historical_packets: historical_packets_map)
end
end
@spec within_bounds?(map() | struct(), map()) :: boolean()
defp within_bounds?(packet, bounds) do
{lat, lon, _data_extended} = MapHelpers.get_coordinates(packet)
# Basic validation
if is_nil(lat) or is_nil(lon) 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
lon >= bounds.west && lon <= bounds.east
else
# Bounds cross antimeridian (e.g., west=170, east=-170)
lon >= bounds.west || lon <= bounds.east
end
lat_in_bounds && lng_in_bounds
end
end
@spec build_packet_data(map() | struct()) :: map() | nil
defp build_packet_data(packet) do
{lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
# Only include packets with valid position data and a non-empty callsign
if lat && lon && callsign != "" && callsign != nil do
build_packet_map(packet, lat, lon, data_extended)
end
end
@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 || %{}
packet_info = extract_packet_info(packet, data_extended)
popup = build_popup_content(packet, packet_info, lat, lon)
build_packet_result(packet, packet_info, lat, lon, popup)
end
defp extract_packet_info(packet, data_extended) do
%{
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(