- Delete dead config keys and test file (typo'd aprsme_is_*, vacuous disable test) - Remove duplicate function clauses and identical function pairs - Rename misleading one_hour_ago variable to one_day_ago - Strip stale Oban comment - Remove TestHelpers time function duplicates - Inline Aprsme.Schema module (only 1 caller) - Deduplicate prod esbuild config - Thin SymbolRenderer delegations, inline TimeUtils wrappers - Remove redundant DeploymentNotifier GenServer polling - Replace random fallback in get_callsign_key with sentinel
2049 lines
71 KiB
Elixir
2049 lines
71 KiB
Elixir
defmodule AprsmeWeb.MapLive.Index do
|
|
@moduledoc """
|
|
LiveView for displaying real-time APRS packets on a map
|
|
"""
|
|
use AprsmeWeb, :live_view
|
|
|
|
import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
|
|
import AprsmeWeb.MapLive.Components
|
|
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
|
|
|
import Phoenix.LiveView,
|
|
only: [connected?: 1, get_connect_params: 1, push_event: 3, push_patch: 2, put_flash: 3]
|
|
|
|
alias Aprsme.Packets
|
|
alias Aprsme.Packets.Clustering
|
|
alias AprsmeWeb.Live.Shared.BoundsUtils
|
|
alias AprsmeWeb.Live.Shared.CoordinateUtils
|
|
alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils
|
|
alias AprsmeWeb.Live.Shared.ParamUtils
|
|
alias AprsmeWeb.MapLive.DataBuilder
|
|
alias AprsmeWeb.MapLive.DisplayManager
|
|
alias AprsmeWeb.MapLive.HistoricalLoader
|
|
alias AprsmeWeb.MapLive.Navigation
|
|
alias AprsmeWeb.MapLive.PacketBatcher
|
|
alias AprsmeWeb.MapLive.PacketProcessor
|
|
alias AprsmeWeb.MapLive.RfPath
|
|
alias AprsmeWeb.MapLive.UrlParams
|
|
alias AprsmeWeb.TimeUtils
|
|
alias Phoenix.LiveView.JS
|
|
|
|
# Import the new components module
|
|
alias Phoenix.LiveView.Socket
|
|
alias Phoenix.Socket.Broadcast
|
|
|
|
require Logger
|
|
|
|
@impl true
|
|
def mount(params, session, socket) do
|
|
socket = setup_subscriptions(socket)
|
|
|
|
# Basic setup
|
|
deployed_at = Aprsme.Release.deployed_at()
|
|
one_day_ago = TimeUtils.hours_ago(24)
|
|
|
|
# Parse and determine map location
|
|
{map_center, map_zoom, should_skip_initial_url_update} = Navigation.determine_map_location(params, session)
|
|
|
|
# Parse trail duration and historical hours from URL
|
|
trail_duration = params |> Map.get("trail", "1") |> parse_trail_duration() |> to_string()
|
|
historical_hours = params |> Map.get("hist", "1") |> parse_historical_hours() |> to_string()
|
|
|
|
# Calculate packet age threshold based on trail duration
|
|
hours = String.to_integer(trail_duration)
|
|
packet_age_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
|
|
|
|
# Setup defaults with parsed values
|
|
socket = assign_defaults(socket, packet_age_threshold)
|
|
|
|
socket =
|
|
assign(socket,
|
|
initial_historical_completed: false,
|
|
trail_duration: trail_duration,
|
|
historical_hours: historical_hours
|
|
)
|
|
|
|
# Setup additional subscriptions if connected
|
|
socket = setup_additional_subscriptions(socket)
|
|
|
|
# Handle callsign tracking - check path params first, then query params
|
|
tracked_callsign =
|
|
case Map.get(params, "callsign") || Map.get(params, "call") do
|
|
callsign when is_binary(callsign) and callsign != "" ->
|
|
callsign |> String.trim() |> String.upcase()
|
|
|
|
_ ->
|
|
""
|
|
end
|
|
|
|
{final_map_center, final_map_zoom} =
|
|
Navigation.handle_callsign_tracking(
|
|
tracked_callsign,
|
|
map_center,
|
|
map_zoom,
|
|
UrlParams.has_explicit_url_params?(params)
|
|
)
|
|
|
|
# Calculate initial bounds
|
|
initial_bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom)
|
|
|
|
# Final socket assignment
|
|
{:ok,
|
|
finalize_mount_assigns(socket, %{
|
|
initial_bounds: initial_bounds,
|
|
final_map_center: final_map_center,
|
|
final_map_zoom: final_map_zoom,
|
|
should_skip_initial_url_update: should_skip_initial_url_update,
|
|
tracked_callsign: tracked_callsign,
|
|
deployed_at: deployed_at,
|
|
one_day_ago: one_day_ago
|
|
})}
|
|
end
|
|
|
|
defp setup_subscriptions(socket) do
|
|
do_setup_subscriptions(socket, connected?(socket))
|
|
end
|
|
|
|
defp do_setup_subscriptions(socket, true) do
|
|
# Check if we should accept new connections
|
|
if Application.get_env(:aprsme, :cluster_enabled, false) and
|
|
not Aprsme.ConnectionMonitor.accepting_connections?() do
|
|
# Redirect to another node or show message
|
|
socket
|
|
|> put_flash(:info, "This server is currently at capacity. Please try again in a moment.")
|
|
|> push_event("redirect_to_least_loaded", %{})
|
|
|> assign(:connection_draining, true)
|
|
else
|
|
# Generate a unique client ID for this LiveView instance
|
|
client_id = "liveview_#{:erlang.phash2(self())}"
|
|
|
|
# Register with connection monitor
|
|
_ =
|
|
if Application.get_env(:aprsme, :cluster_enabled, false) do
|
|
_ = Aprsme.ConnectionMonitor.register_connection()
|
|
# Subscribe to drain events for this node
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "connection:drain:#{Node.self()}")
|
|
end
|
|
|
|
# Register with spatial PubSub (will get viewport later)
|
|
# Start with a default viewport that will be updated when we get actual bounds
|
|
default_bounds = %{north: 90.0, south: -90.0, east: 180.0, west: -180.0}
|
|
{:ok, spatial_topic} = Aprsme.SpatialPubSub.register_viewport(client_id, default_bounds)
|
|
|
|
# Subscribe to the spatial topic for this client
|
|
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, spatial_topic)
|
|
|
|
# Still subscribe to bad packets (they don't have location)
|
|
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets")
|
|
|
|
# Subscribe to deployment events
|
|
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "deployment_events")
|
|
|
|
# Subscribe to StreamingPacketsPubSub with initial bounds
|
|
_ = Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), default_bounds)
|
|
|
|
_ = Process.send_after(self(), :cleanup_old_packets, 60_000)
|
|
|
|
socket
|
|
|> assign(:spatial_client_id, client_id)
|
|
|> assign(:connection_draining, false)
|
|
end
|
|
end
|
|
|
|
defp do_setup_subscriptions(socket, false), do: socket
|
|
|
|
defp setup_additional_subscriptions(socket) do
|
|
do_setup_additional_subscriptions(socket, connected?(socket))
|
|
end
|
|
|
|
defp do_setup_additional_subscriptions(socket, true) do
|
|
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
|
socket
|
|
end
|
|
|
|
defp do_setup_additional_subscriptions(socket, false), do: socket
|
|
|
|
defp finalize_mount_assigns(socket, %{
|
|
initial_bounds: initial_bounds,
|
|
final_map_center: final_map_center,
|
|
final_map_zoom: final_map_zoom,
|
|
should_skip_initial_url_update: should_skip_initial_url_update,
|
|
tracked_callsign: tracked_callsign,
|
|
deployed_at: deployed_at,
|
|
one_day_ago: one_day_ago
|
|
}) do
|
|
# Don't override trail_duration and historical_hours if they're already set
|
|
trail_duration = Map.get(socket.assigns, :trail_duration, "1")
|
|
historical_hours = Map.get(socket.assigns, :historical_hours, "1")
|
|
packet_age_threshold = Map.get(socket.assigns, :packet_age_threshold, one_day_ago)
|
|
|
|
# If tracking a specific callsign, fetch their latest packet and other SSIDs
|
|
{tracked_callsign_latest_packet, other_ssids} =
|
|
if tracked_callsign == "" do
|
|
{nil, []}
|
|
else
|
|
try do
|
|
packet = Packets.get_latest_packet_for_callsign(tracked_callsign)
|
|
ssids = Packets.get_other_ssids(tracked_callsign)
|
|
{packet, ssids}
|
|
rescue
|
|
# Handle database connection errors gracefully (especially in tests)
|
|
DBConnection.OwnershipError ->
|
|
{nil, []}
|
|
|
|
_ ->
|
|
{nil, []}
|
|
end
|
|
end
|
|
|
|
# Start packet batcher for efficient updates
|
|
{:ok, batcher_pid} = PacketBatcher.start_link(self())
|
|
Process.monitor(batcher_pid)
|
|
|
|
# Determine initial slideover state from client viewport width
|
|
slideover_open = initial_slideover_open?(socket)
|
|
|
|
assign(socket,
|
|
map_ready: false,
|
|
map_bounds: initial_bounds,
|
|
map_center: final_map_center,
|
|
map_zoom: final_map_zoom,
|
|
should_skip_initial_url_update: should_skip_initial_url_update,
|
|
overlay_callsign: "",
|
|
tracked_callsign: tracked_callsign,
|
|
tracked_callsign_latest_packet: tracked_callsign_latest_packet,
|
|
other_ssids: other_ssids,
|
|
trail_duration: trail_duration,
|
|
historical_hours: historical_hours,
|
|
packet_age_threshold: packet_age_threshold,
|
|
slideover_open: slideover_open,
|
|
deployed_at: deployed_at,
|
|
buffer_timer: nil,
|
|
batcher_pid: batcher_pid,
|
|
station_popup_open: false,
|
|
initial_bounds_loaded: false,
|
|
# Always load historical data on initial page load
|
|
needs_initial_historical_load: true,
|
|
# Loading state management
|
|
historical_loading: false,
|
|
loading_generation: 0,
|
|
pending_batch_tasks: [],
|
|
# Add missing assigns for components
|
|
loading: false,
|
|
connection_status: get_initial_connection_status(),
|
|
show_all_packets: true
|
|
)
|
|
end
|
|
|
|
defp get_initial_connection_status do
|
|
# Check if APRS connection is disabled
|
|
connection_disabled = Application.get_env(:aprsme, :disable_aprs_connection, false)
|
|
if connection_disabled, do: "disconnected", else: "connected"
|
|
end
|
|
|
|
defp initial_slideover_open?(socket) do
|
|
if connected?(socket) do
|
|
viewport_width = get_connect_params(socket)["viewport_width"] || 1024
|
|
viewport_width >= 1024
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
@spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t()
|
|
defp assign_defaults(socket, one_day_ago) do
|
|
assign(socket,
|
|
packets: [],
|
|
visible_packets: %{},
|
|
historical_packets: %{},
|
|
page_title: "APRS Map",
|
|
station_popup_open: false,
|
|
map_bounds: %{
|
|
north: 49.0,
|
|
south: 24.0,
|
|
east: -66.0,
|
|
west: -125.0
|
|
},
|
|
map_center: UrlParams.default_center(),
|
|
map_zoom: UrlParams.default_zoom(),
|
|
packet_age_threshold: one_day_ago,
|
|
map_ready: false,
|
|
historical_loaded: false,
|
|
bounds_update_timer: nil,
|
|
pending_bounds: nil,
|
|
initial_bounds_loaded: false,
|
|
# Loading state management
|
|
historical_loading: false,
|
|
loading_generation: 0,
|
|
pending_batch_tasks: [],
|
|
# Overlay controls
|
|
overlay_callsign: "",
|
|
slideover_open: true,
|
|
# Track when last update occurred for real-time display in map sidebar
|
|
# Updated when packets are processed or map bounds change
|
|
last_update_at: DateTime.utc_now(),
|
|
# Add missing assigns for components
|
|
loading: false,
|
|
connection_status: get_initial_connection_status(),
|
|
show_all_packets: true
|
|
)
|
|
end
|
|
|
|
# Handle both bounds_changed and update_bounds events
|
|
@impl true
|
|
def handle_event(event, %{"bounds" => bounds}, socket) when event in ["bounds_changed", "update_bounds"] do
|
|
Logger.debug("Received #{event} event with bounds: #{inspect(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
|
|
# Geolocation sends numeric values; coerce strings to floats if needed
|
|
lat_float = to_float(lat)
|
|
lng_float = to_float(lng)
|
|
|
|
if UrlParams.valid_coordinates?(lat_float, lng_float) do
|
|
socket = Navigation.update_and_zoom_to_location(socket, lat_float, lng_float, 12)
|
|
{:noreply, socket}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_and_reload_markers", _params, socket) do
|
|
# Filter by time and bounds
|
|
filtered_packets =
|
|
filter_packets_by_time_and_bounds_with_tracked(
|
|
socket.assigns.visible_packets,
|
|
socket.assigns.map_bounds,
|
|
socket.assigns.packet_age_threshold,
|
|
socket.assigns.tracked_callsign,
|
|
socket.assigns.tracked_callsign_latest_packet
|
|
)
|
|
|
|
visible_packets_list = DataBuilder.build_packet_data_list_from_map(filtered_packets, false, socket)
|
|
|
|
# Check zoom level to decide between heat map and markers
|
|
socket =
|
|
if socket.assigns.map_zoom <= 8 do
|
|
# Use heat map for low zoom levels
|
|
send_heat_map_data(socket, filtered_packets)
|
|
else
|
|
# Use regular markers for high zoom levels
|
|
add_markers_if_any(socket, visible_packets_list)
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("map_ready", _params, socket) do
|
|
# Mark map as ready - preserve existing needs_initial_historical_load state
|
|
# (it's already set correctly in mount based on whether we're tracking a callsign)
|
|
socket = assign(socket, map_ready: true)
|
|
|
|
# If we have non-default center coordinates (e.g., from geolocation), apply them now
|
|
default_center = UrlParams.default_center()
|
|
|
|
socket =
|
|
if socket.assigns.map_center.lat == default_center.lat and
|
|
socket.assigns.map_center.lng == default_center.lng do
|
|
socket
|
|
else
|
|
Navigation.zoom_to_current_location(socket)
|
|
end
|
|
|
|
# If we're tracking a callsign and have its latest packet, display it immediately
|
|
socket =
|
|
if socket.assigns.tracked_callsign != "" and socket.assigns.tracked_callsign_latest_packet do
|
|
packet = socket.assigns.tracked_callsign_latest_packet
|
|
packet_data = DataBuilder.build_packet_data(packet)
|
|
|
|
push_event(socket, "add_historical_packets_batch", %{
|
|
packets: [packet_data],
|
|
batch: 0,
|
|
is_final: false
|
|
})
|
|
else
|
|
socket
|
|
end
|
|
|
|
# Wait for JavaScript to send the actual map bounds before loading historical packets
|
|
# The calculated bounds might be too small/inaccurate
|
|
Logger.debug("Map ready - waiting for JavaScript to send actual bounds before loading historical packets")
|
|
|
|
# If we need initial historical load and have bounds, trigger it
|
|
socket =
|
|
if socket.assigns.needs_initial_historical_load and socket.assigns.map_bounds do
|
|
Logger.debug("Map ready with needs_initial_historical_load=true, triggering historical loading")
|
|
# Send a message to trigger bounds processing
|
|
send(self(), {:process_bounds_update, socket.assigns.map_bounds})
|
|
socket
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("marker_clicked", _params, socket) do
|
|
# When a marker is clicked, mark that a station popup is open
|
|
{:noreply, assign(socket, station_popup_open: true)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("marker_hover_start", %{"id" => _id, "path" => path, "lat" => lat, "lng" => lng}, socket) do
|
|
# Cancel any pending hover end timer
|
|
socket =
|
|
if socket.assigns[:hover_end_timer] do
|
|
_ = Process.cancel_timer(socket.assigns.hover_end_timer)
|
|
assign(socket, hover_end_timer: nil)
|
|
else
|
|
socket
|
|
end
|
|
|
|
# Validate coordinates first
|
|
lat_float = ParamUtils.safe_parse_coordinate(lat, 0.0, -90.0, 90.0)
|
|
lng_float = ParamUtils.safe_parse_coordinate(lng, 0.0, -180.0, 180.0)
|
|
|
|
# Validate path string
|
|
safe_path = ParamUtils.sanitize_path_string(path)
|
|
|
|
# Parse the path to find digipeater/igate stations
|
|
path_stations = RfPath.parse_rf_path(safe_path)
|
|
|
|
# Query for positions of path stations
|
|
path_station_positions = RfPath.get_path_station_positions(path_stations, socket)
|
|
|
|
# Send event to draw the RF path lines
|
|
socket =
|
|
if path_station_positions == [] do
|
|
socket
|
|
else
|
|
push_event(socket, "draw_rf_path", %{
|
|
station_lat: lat_float,
|
|
station_lng: lng_float,
|
|
path_stations: path_station_positions
|
|
})
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("marker_hover_end", _params, socket) do
|
|
# Debounce hover end to prevent flicker during rapid mouse movements
|
|
timer = Process.send_after(self(), :clear_rf_path, 100)
|
|
{:noreply, assign(socket, hover_end_timer: timer)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_callsign", %{"callsign" => callsign}, socket) do
|
|
{:noreply, assign(socket, overlay_callsign: callsign)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("track_callsign", %{"callsign" => callsign}, socket) do
|
|
normalized_callsign = String.upcase(String.trim(callsign))
|
|
|
|
socket =
|
|
if normalized_callsign == "" do
|
|
# Clear tracking
|
|
socket
|
|
|> assign(tracked_callsign: "", other_ssids: [])
|
|
|> update_url_with_current_state()
|
|
else
|
|
# Set tracking, fetch latest packet, zoom to location, and show marker
|
|
other_ssids = Packets.get_other_ssids(normalized_callsign)
|
|
latest_packet = Packets.get_latest_packet_for_callsign(normalized_callsign)
|
|
|
|
socket =
|
|
assign(socket,
|
|
tracked_callsign: normalized_callsign,
|
|
other_ssids: other_ssids,
|
|
tracked_callsign_latest_packet: latest_packet
|
|
)
|
|
|
|
# Zoom to the callsign's location and display its marker
|
|
socket =
|
|
if latest_packet && latest_packet.lat && latest_packet.lon do
|
|
lat = Aprsme.EncodingUtils.to_float(latest_packet.lat) || 0.0
|
|
lon = Aprsme.EncodingUtils.to_float(latest_packet.lon) || 0.0
|
|
|
|
packet_data = DataBuilder.build_packet_data(latest_packet)
|
|
|
|
socket
|
|
|> push_event("zoom_to_location", %{lat: lat, lng: lon, zoom: 12})
|
|
|> push_event("add_historical_packets_batch", %{
|
|
packets: [packet_data],
|
|
batch: 0,
|
|
is_final: false
|
|
})
|
|
else
|
|
socket
|
|
end
|
|
|
|
push_patch(socket, to: "/#{normalized_callsign}")
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_tracking", _params, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(tracked_callsign: "", overlay_callsign: "", other_ssids: [])
|
|
|> push_event("clear_trail_line", %{})
|
|
|> update_url_with_current_state()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket) do
|
|
# Validate and convert duration string to hours
|
|
hours = parse_trail_duration(duration)
|
|
|
|
# Calculate new threshold safely
|
|
new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
|
|
|
|
socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold)
|
|
|
|
# Update client-side TrailManager with new duration
|
|
socket = push_event(socket, "update_trail_duration", %{duration_hours: hours})
|
|
|
|
# Update URL with new trail duration
|
|
socket = update_url_with_current_state(socket)
|
|
|
|
# Trigger cleanup to remove packets that are now outside the new duration
|
|
send(self(), :cleanup_old_packets)
|
|
|
|
# If tracking a callsign at low zoom, refresh the trail line with new duration
|
|
socket =
|
|
if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do
|
|
DisplayManager.send_trail_line_for_tracked_callsign(socket)
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket) do
|
|
# Validate hours value
|
|
validated_hours = parse_historical_hours(hours)
|
|
socket = assign(socket, historical_hours: to_string(validated_hours))
|
|
|
|
# Update URL with new historical hours
|
|
socket = update_url_with_current_state(socket)
|
|
|
|
# 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
|
|
callsign
|
|
|> String.trim()
|
|
|> String.upcase()
|
|
|> handle_callsign_search(socket)
|
|
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
|
|
|
|
@impl true
|
|
def handle_event("popup_closed", _params, socket) do
|
|
# When any popup is closed, mark that no station popup is open
|
|
{:noreply, assign(socket, station_popup_open: false)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("get_assigns", _params, socket) do
|
|
send(self(), {:test_assigns, socket.assigns})
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_map_state", %{"center" => center, "zoom" => zoom} = params, socket) do
|
|
Logger.debug("update_map_state event received: center=#{inspect(center)}, zoom=#{zoom}")
|
|
|
|
# Parse and validate coordinates
|
|
{lat, lng} = parse_center_coordinates(center, socket)
|
|
zoom = clamp_zoom(zoom)
|
|
map_center = %{lat: lat, lng: lng}
|
|
|
|
# Update map state
|
|
socket = update_map_state(socket, map_center, zoom)
|
|
|
|
# Handle URL updates
|
|
socket = handle_url_update(socket, lat, lng, zoom)
|
|
|
|
# Process bounds if included
|
|
socket = process_bounds_from_params(socket, params)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event(
|
|
"error_boundary_triggered",
|
|
%{"message" => message, "stack" => stack, "component_id" => component_id},
|
|
socket
|
|
) do
|
|
# Log the error for monitoring
|
|
Logger.error("Error boundary triggered in component #{component_id}: #{message}\n#{stack}")
|
|
|
|
# You could also send this to an error tracking service here
|
|
# ErrorTracker.report_error(message, stack, %{component: component_id, user_id: socket.assigns[:current_user_id]})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp parse_center_coordinates(center, socket) when is_map(center) do
|
|
CoordinateUtils.parse_center_coordinates(center, socket)
|
|
end
|
|
|
|
defp parse_center_coordinates(_, socket) do
|
|
CoordinateUtils.parse_center_coordinates(nil, socket)
|
|
end
|
|
|
|
defp clamp_zoom(zoom) do
|
|
ParamUtils.clamp_zoom(zoom)
|
|
end
|
|
|
|
defp update_map_state(socket, map_center, zoom) do
|
|
old_zoom = socket.assigns.map_zoom
|
|
crossing_threshold = crossing_zoom_threshold?(old_zoom, zoom)
|
|
|
|
socket = assign(socket, map_center: map_center, map_zoom: zoom)
|
|
|
|
if crossing_threshold do
|
|
handle_zoom_threshold_crossing(socket, zoom)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp crossing_zoom_threshold?(old_zoom, new_zoom) do
|
|
DisplayManager.crossing_zoom_threshold?(old_zoom, new_zoom)
|
|
end
|
|
|
|
defp handle_zoom_threshold_crossing(socket, zoom) do
|
|
DisplayManager.handle_zoom_threshold_crossing(socket, zoom)
|
|
end
|
|
|
|
defp update_url_with_current_state(socket) do
|
|
lat = socket.assigns.map_center.lat
|
|
lng = socket.assigns.map_center.lng
|
|
zoom = socket.assigns.map_zoom
|
|
handle_url_update(socket, lat, lng, zoom)
|
|
end
|
|
|
|
defp handle_url_update(socket, lat, lng, zoom) do
|
|
if socket.assigns[:should_skip_initial_url_update] && !socket.assigns[:initial_bounds_loaded] do
|
|
Logger.debug("Skipping URL update on initial load")
|
|
assign(socket, should_skip_initial_url_update: false)
|
|
else
|
|
# Include trail duration and historical hours in URL
|
|
trail_param =
|
|
if socket.assigns[:trail_duration] && socket.assigns[:trail_duration] != "1",
|
|
do: "&trail=#{socket.assigns[:trail_duration]}",
|
|
else: ""
|
|
|
|
hist_param =
|
|
if socket.assigns[:historical_hours] && socket.assigns[:historical_hours] != "1",
|
|
do: "&hist=#{socket.assigns[:historical_hours]}",
|
|
else: ""
|
|
|
|
# Preserve callsign in path if tracking
|
|
base_path =
|
|
if socket.assigns.tracked_callsign == "" do
|
|
"/"
|
|
else
|
|
"/#{socket.assigns.tracked_callsign}"
|
|
end
|
|
|
|
new_path = "#{base_path}?lat=#{lat}&lng=#{lng}&z=#{zoom}#{trail_param}#{hist_param}"
|
|
Logger.debug("Updating URL to: #{new_path}")
|
|
push_patch(socket, to: new_path, replace: true)
|
|
end
|
|
end
|
|
|
|
defp process_bounds_from_params(socket, params) do
|
|
case Map.get(params, "bounds") do
|
|
%{"north" => north, "south" => south, "east" => east, "west" => west} ->
|
|
map_bounds = %{north: north, south: south, east: east, west: west}
|
|
|
|
if should_process_bounds?(socket, map_bounds) do
|
|
Logger.debug(
|
|
"Sending bounds update (initial_load: #{!socket.assigns[:initial_bounds_loaded]}, " <>
|
|
"needs_historical: #{socket.assigns[:needs_initial_historical_load]}): #{inspect(map_bounds)}"
|
|
)
|
|
|
|
send(self(), {:process_bounds_update, map_bounds})
|
|
end
|
|
|
|
socket
|
|
|
|
_ ->
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp should_process_bounds?(socket, new_bounds) do
|
|
socket.assigns.map_bounds != new_bounds or
|
|
!socket.assigns[:initial_bounds_loaded] or
|
|
socket.assigns[:needs_initial_historical_load]
|
|
end
|
|
|
|
defp handle_callsign_search(callsign, socket) do
|
|
Navigation.handle_callsign_search(callsign, socket)
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
# Check if we should skip this update (e.g., when using IP geolocation on initial load)
|
|
if Map.get(socket.assigns, :should_skip_initial_url_update, false) and
|
|
not Map.get(socket.assigns, :map_ready, false) do
|
|
# Skip the URL parameter update to preserve IP geolocation
|
|
socket = assign(socket, should_skip_initial_url_update: false)
|
|
{:noreply, socket}
|
|
else
|
|
# Parse new map state from URL parameters
|
|
{map_center, map_zoom} = UrlParams.parse_map_params(params)
|
|
|
|
# Parse trail duration and historical hours from URL
|
|
trail_duration = params |> Map.get("trail", "1") |> parse_trail_duration() |> to_string()
|
|
historical_hours = params |> Map.get("hist", "1") |> parse_historical_hours() |> to_string()
|
|
|
|
# Update socket state
|
|
socket =
|
|
socket
|
|
|> assign(map_center: map_center, map_zoom: map_zoom)
|
|
|> assign(trail_duration: trail_duration, historical_hours: historical_hours)
|
|
|
|
# Update packet age threshold based on trail duration
|
|
hours = String.to_integer(trail_duration)
|
|
new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
|
|
socket = assign(socket, packet_age_threshold: new_threshold)
|
|
|
|
# If map is ready, update the client-side map and trail duration
|
|
socket =
|
|
if socket.assigns.map_ready do
|
|
socket
|
|
|> push_event("zoom_to_location", %{
|
|
lat: map_center.lat,
|
|
lng: map_center.lng,
|
|
zoom: map_zoom
|
|
})
|
|
|> push_event("update_trail_duration", %{duration_hours: hours})
|
|
else
|
|
socket
|
|
end
|
|
|
|
# Trigger cleanup and reload if settings changed
|
|
if socket.assigns[:map_ready] do
|
|
send(self(), :cleanup_old_packets)
|
|
send(self(), :reload_historical_packets)
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
# Parse trail duration with validation and bounds checking
|
|
defp parse_trail_duration(duration), do: SharedPacketUtils.parse_trail_duration(duration)
|
|
|
|
# Parse historical hours with validation
|
|
defp parse_historical_hours(hours), do: SharedPacketUtils.parse_historical_hours(hours)
|
|
|
|
defp to_float(val) when is_float(val), do: val
|
|
defp to_float(val) when is_integer(val), do: val / 1
|
|
|
|
defp to_float(val) when is_binary(val) do
|
|
case Float.parse(val) do
|
|
{f, _} -> f
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp to_float(_), do: nil
|
|
|
|
@impl true
|
|
def handle_info({:process_bounds_update, map_bounds}, socket),
|
|
do: handle_info_process_bounds_update(map_bounds, 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
|
|
# Add packet to batcher instead of processing immediately
|
|
_ =
|
|
if socket.assigns[:batcher_pid] do
|
|
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
|
|
else
|
|
handle_info_postgres_packet(packet, socket)
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info({:spatial_packet, packet}, socket) do
|
|
# Add packet to batcher instead of processing immediately
|
|
_ =
|
|
if socket.assigns[:batcher_pid] do
|
|
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
|
|
else
|
|
handle_info_postgres_packet(packet, socket)
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info({:streaming_packet, packet}, socket) do
|
|
# Add packet to batcher instead of processing immediately
|
|
_ =
|
|
if socket.assigns[:batcher_pid] do
|
|
PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
|
|
else
|
|
handle_info_postgres_packet(packet, socket)
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info({:packet_batch, packets}, socket) do
|
|
{socket, marker_data_list, removal_ids} = process_packet_batch(packets, socket)
|
|
|
|
socket =
|
|
socket
|
|
|> remove_markers_if_needed(removal_ids)
|
|
|> assign(:last_update_at, DateTime.utc_now())
|
|
|> update_display_for_batch(marker_data_list)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info({:DOWN, _ref, :process, pid, _reason}, socket) when pid == socket.assigns.batcher_pid do
|
|
# PacketBatcher crashed — restart it
|
|
{:ok, new_pid} = PacketBatcher.start_link(self())
|
|
Process.monitor(new_pid)
|
|
{:noreply, assign(socket, :batcher_pid, new_pid)}
|
|
end
|
|
|
|
def handle_info(:clear_rf_path, socket) do
|
|
# Clear the RF path lines with debouncing
|
|
{:noreply, push_event(socket, "clear_rf_path", %{})}
|
|
end
|
|
|
|
def handle_info(
|
|
%Broadcast{topic: "deployment_events", payload: {:new_deployment, %{deployed_at: deployed_at}}},
|
|
socket
|
|
) do
|
|
{:noreply, assign(socket, :deployed_at, deployed_at)}
|
|
end
|
|
|
|
# Phoenix.PubSub.broadcast delivers the raw payload (no %Broadcast{} wrapper)
|
|
# to plain subscribers. DeploymentNotifier broadcasts via that path.
|
|
def handle_info({:new_deployment, %{deployed_at: deployed_at}}, socket) do
|
|
{:noreply, assign(socket, :deployed_at, deployed_at)}
|
|
end
|
|
|
|
def handle_info({:drain_connections, to_drain}, socket) do
|
|
if :rand.uniform(100) <= to_drain * 10 do
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Server load balancing in progress. Reconnecting...")
|
|
|> push_event("reconnect", %{delay: :rand.uniform(5000)})}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_info({:load_rf_path_station_packets, stations}, socket) do
|
|
# Load the most recent packet for each RF path station in a single batch query
|
|
station_packets = Packets.get_latest_packets_for_callsigns(stations)
|
|
|
|
socket =
|
|
if Enum.any?(station_packets) do
|
|
# Build packet data for the RF path stations
|
|
packet_data_list = DataBuilder.build_packet_data_list(station_packets)
|
|
|
|
# Send these packets to the frontend
|
|
DisplayManager.add_markers_if_any(socket, packet_data_list)
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info({:process_pending_bounds}, socket) do
|
|
if socket.assigns.pending_bounds && !socket.assigns.historical_loading do
|
|
# Process the pending bounds update
|
|
bounds = socket.assigns.pending_bounds
|
|
socket = assign(socket, pending_bounds: nil)
|
|
handle_bounds_update(bounds, socket)
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_info({:load_historical_batch, batch_offset}, socket) do
|
|
# For backward compatibility with old messages
|
|
socket = HistoricalLoader.load_historical_batch(socket, batch_offset, socket.assigns.loading_generation)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info({:load_historical_batch, batch_offset, generation}, socket) do
|
|
# Only process if generation matches current loading generation
|
|
if generation == socket.assigns.loading_generation do
|
|
socket = HistoricalLoader.load_historical_batch(socket, batch_offset, generation)
|
|
{:noreply, socket}
|
|
else
|
|
# Stale request, ignore it
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_info({:historical_loading_timeout, generation}, socket) do
|
|
# Only process if generation matches current loading generation and we're still loading
|
|
if generation == socket.assigns.loading_generation && socket.assigns.historical_loading do
|
|
Logger.warning("Historical loading timeout reached, forcing completion")
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:historical_loading, false)
|
|
|> assign(:loading_batch, socket.assigns.total_batches || 1)
|
|
|
|
{:noreply, socket}
|
|
else
|
|
# Stale timeout or already completed, ignore it
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_info({:show_error, message}, socket) do
|
|
socket = put_flash(socket, :error, message)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
# Private handler functions for each message type
|
|
|
|
defp prepare_markers_for_push(markers, popup_open?) do
|
|
Enum.map(markers, fn data ->
|
|
data = Map.delete(data, "convert_from")
|
|
if popup_open?, do: Map.put(data, :openPopup, false), else: data
|
|
end)
|
|
end
|
|
|
|
defp handle_info_process_bounds_update(map_bounds, socket) do
|
|
# Check if this is a stale update (newer bounds have been scheduled)
|
|
if socket.assigns[:pending_bounds] && socket.assigns.pending_bounds != map_bounds do
|
|
Logger.debug("Skipping stale bounds update, newer bounds pending")
|
|
{:noreply, socket}
|
|
else
|
|
# Clear the timer reference since we're processing this update
|
|
socket = assign(socket, bounds_update_timer: nil, pending_bounds: nil)
|
|
|
|
# Check if we need to process this bounds update
|
|
should_process =
|
|
!socket.assigns[:initial_bounds_loaded] or
|
|
socket.assigns[:needs_initial_historical_load] or
|
|
not BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds)
|
|
|
|
Logger.debug(
|
|
"handle_info_process_bounds_update - should_process: #{should_process}, initial_bounds_loaded: #{socket.assigns[:initial_bounds_loaded]}, needs_initial_historical_load: #{socket.assigns[:needs_initial_historical_load]}"
|
|
)
|
|
|
|
if should_process do
|
|
Logger.debug("Processing bounds update: #{inspect(map_bounds)}")
|
|
socket = process_bounds_update(map_bounds, socket)
|
|
socket = assign(socket, initial_bounds_loaded: true)
|
|
{:noreply, socket}
|
|
else
|
|
Logger.debug("Skipping bounds update - no change detected")
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp handle_info_initialize_replay(socket) do
|
|
if not socket.assigns.historical_loaded and socket.assigns.map_ready do
|
|
# Only proceed if we have actual map bounds - don't use world bounds
|
|
if socket.assigns.map_bounds do
|
|
# Use progressive loading for better performance
|
|
socket = HistoricalLoader.start_progressive_historical_loading(socket)
|
|
socket = assign(socket, historical_loaded: true)
|
|
{:noreply, socket}
|
|
else
|
|
# Wait a bit longer for map bounds to be available
|
|
# Increase delay to give client more time to send real bounds
|
|
Process.send_after(self(), :initialize_replay, 500)
|
|
{:noreply, socket}
|
|
end
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
defp handle_info_postgres_packet(packet, socket) do
|
|
# Check if we're tracking a specific callsign
|
|
# Only process if this packet is from the tracked callsign
|
|
if socket.assigns.tracked_callsign == "" do
|
|
# No tracking - show all packets
|
|
process_packet_for_display(packet, socket)
|
|
else
|
|
packet_sender = Map.get(packet, :sender, Map.get(packet, "sender", ""))
|
|
|
|
if String.upcase(packet_sender) == String.upcase(socket.assigns.tracked_callsign) do
|
|
# Update the tracked callsign's latest packet
|
|
socket =
|
|
assign(
|
|
socket,
|
|
:tracked_callsign_latest_packet,
|
|
preferred_tracked_packet(socket.assigns.tracked_callsign_latest_packet, packet)
|
|
)
|
|
|
|
process_packet_for_display(packet, socket)
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp process_packet_for_display(packet, socket) do
|
|
PacketProcessor.process_packet_for_display(packet, socket)
|
|
end
|
|
|
|
defp process_packet_for_batch(packet, socket) do
|
|
if socket.assigns.tracked_callsign == "" do
|
|
PacketProcessor.process_packet_for_marker_data(packet, socket)
|
|
else
|
|
packet_sender = Map.get(packet, :sender, Map.get(packet, "sender", ""))
|
|
|
|
if String.upcase(packet_sender) == String.upcase(socket.assigns.tracked_callsign) do
|
|
socket =
|
|
assign(
|
|
socket,
|
|
:tracked_callsign_latest_packet,
|
|
preferred_tracked_packet(socket.assigns.tracked_callsign_latest_packet, packet)
|
|
)
|
|
|
|
PacketProcessor.process_packet_for_marker_data(packet, socket)
|
|
else
|
|
{socket, nil, nil}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp process_packet_batch(packets, socket) do
|
|
Enum.reduce(packets, {socket, [], []}, fn packet, {acc_socket, markers, removals} ->
|
|
{new_socket, marker_data, removed_id} = process_packet_for_batch(packet, acc_socket)
|
|
markers = if marker_data, do: [marker_data | markers], else: markers
|
|
removals = if removed_id, do: [removed_id | removals], else: removals
|
|
{new_socket, markers, removals}
|
|
end)
|
|
end
|
|
|
|
defp remove_markers_if_needed(socket, []), do: socket
|
|
|
|
defp remove_markers_if_needed(socket, removal_ids) do
|
|
DisplayManager.remove_markers_batch(socket, removal_ids)
|
|
end
|
|
|
|
defp update_display_for_batch(socket, []), do: socket
|
|
|
|
# PacketProcessor.build_marker_data returns nil when zoom <= 8, so by the time
|
|
# marker_data_list is non-empty here, zoom is always > 8. Heat-map / trail-line
|
|
# rendering for low-zoom packet batches happens inside the per-packet handler.
|
|
defp update_display_for_batch(socket, marker_data_list), do: send_marker_batch(socket, marker_data_list)
|
|
|
|
defp send_marker_batch(socket, marker_data_list) do
|
|
reversed = Enum.reverse(marker_data_list)
|
|
|
|
# Deduplicate by callsign_group — when multiple senders track the same
|
|
# object (e.g. two stations reporting the same radiosonde), keep only
|
|
# the most recent packet per group to avoid duplicate "current" markers.
|
|
deduped =
|
|
reversed
|
|
|> Enum.reduce(%{}, fn marker, acc ->
|
|
group = marker["callsign_group"] || marker["callsign"]
|
|
Map.put(acc, group, marker)
|
|
end)
|
|
|> Map.values()
|
|
|
|
# Send callsign_groups so the frontend can look up existing markers
|
|
# via its callsignIndex (keyed by callsign_group, not by packet UUID)
|
|
convert_to_historical =
|
|
deduped
|
|
|> Enum.map(fn m -> m["callsign_group"] || m["callsign"] end)
|
|
|> Enum.filter(& &1)
|
|
|> Enum.uniq()
|
|
|
|
markers = prepare_markers_for_push(deduped, socket.assigns.station_popup_open)
|
|
|
|
push_event(socket, "new_packets", %{
|
|
packets: markers,
|
|
convert_to_historical: convert_to_historical
|
|
})
|
|
end
|
|
|
|
# Handle replaying the next historical packet
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<.map_styles />
|
|
|
|
<.map_container
|
|
slideover_open={@slideover_open}
|
|
map_center={@map_center}
|
|
map_zoom={@map_zoom}
|
|
/>
|
|
|
|
<.locate_button />
|
|
|
|
<.bottom_controls {assigns} />
|
|
"""
|
|
end
|
|
|
|
# Additional component functions that weren't extracted yet
|
|
defp locate_button(assigns) do
|
|
~H"""
|
|
<button class="locate-button" phx-click="locate_me" title="Locate me">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
"""
|
|
end
|
|
|
|
defp toggle_slideover_js do
|
|
"toggle_slideover"
|
|
|> JS.push()
|
|
|> JS.toggle_class("slideover-open", to: "#aprs-map")
|
|
|> JS.toggle_class("slideover-closed", to: "#aprs-map")
|
|
|> JS.dispatch("phx:map-resize", to: "#aprs-map")
|
|
end
|
|
|
|
defp bottom_controls(assigns) do
|
|
~H"""
|
|
<%!-- Existing bottom controls code will go here --%>
|
|
<style>
|
|
.locate-button {
|
|
position: absolute;
|
|
left: 12px;
|
|
top: 100px;
|
|
z-index: 1000;
|
|
background: white;
|
|
border: 2px solid rgba(0,0,0,0.2);
|
|
border-radius: 4px;
|
|
padding: 5px;
|
|
cursor: pointer;
|
|
color: #333;
|
|
}
|
|
|
|
.locate-button:hover {
|
|
background: #f4f4f4;
|
|
}
|
|
|
|
/* Mobile: larger touch targets push zoom controls taller */
|
|
@media (max-width: 1023px) {
|
|
.locate-button {
|
|
top: 110px;
|
|
width: 44px;
|
|
height: 44px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
.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-info-link {
|
|
font-size: 11px;
|
|
color: #007cba;
|
|
text-decoration: none;
|
|
font-weight: normal;
|
|
margin-left: 8px;
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.aprs-info-link:hover {
|
|
background-color: rgba(0, 124, 186, 0.1);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.aprs-comment {
|
|
color: #374151;
|
|
margin-bottom: 4px;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.aprs-coords {
|
|
color: #6b7280;
|
|
font-size: 11px;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.aprs-timestamp {
|
|
color: #6b7280;
|
|
font-size: 11px;
|
|
font-family: monospace;
|
|
padding-top: 4px;
|
|
}
|
|
|
|
/* Leaflet popup improvements for APRS data */
|
|
.leaflet-popup-content-wrapper {
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.leaflet-popup-content {
|
|
margin: 8px 12px;
|
|
}
|
|
|
|
/* Slideover panel styles */
|
|
.slideover-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
height: 100vh;
|
|
background: white;
|
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
transition: transform 0.3s ease-in-out;
|
|
overflow: hidden;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.slideover-panel {
|
|
background: rgb(15 23 42); /* slate-900 */
|
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
|
|
}
|
|
}
|
|
|
|
/* Ensure proper box-sizing for all children */
|
|
.slideover-panel * {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
/* Desktop styles */
|
|
@media (min-width: 1024px) {
|
|
.slideover-panel {
|
|
width: 352px;
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.slideover-panel.slideover-closed {
|
|
transform: translateX(100%);
|
|
}
|
|
}
|
|
|
|
/* Mobile styles - override the desktop styles */
|
|
@media (max-width: 1023px) {
|
|
.slideover-panel {
|
|
width: 90vw !important;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.slideover-panel.slideover-closed {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
.slideover-panel.slideover-open {
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
/* Slideover toggle button */
|
|
.slideover-toggle {
|
|
position: fixed;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
z-index: 999;
|
|
background: white;
|
|
color: #374151;
|
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
|
border-radius: 8px 0 0 8px;
|
|
padding: 12px 8px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease-in-out;
|
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.slideover-toggle.slideover-open {
|
|
right: 352px;
|
|
}
|
|
|
|
.slideover-toggle.slideover-closed {
|
|
right: 0;
|
|
border-right: none;
|
|
}
|
|
|
|
@media (max-width: 1023px) {
|
|
.slideover-toggle.slideover-open {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.slideover-toggle:hover {
|
|
background: #f3f4f6;
|
|
}
|
|
</style>
|
|
|
|
<!-- Slideover Toggle Button -->
|
|
<button
|
|
class={["slideover-toggle", if(@slideover_open, do: "slideover-open", else: "slideover-closed")]}
|
|
phx-click={toggle_slideover_js()}
|
|
title={
|
|
if @slideover_open,
|
|
do: Gettext.gettext(AprsmeWeb.Gettext, "Hide controls"),
|
|
else: Gettext.gettext(AprsmeWeb.Gettext, "Show controls")
|
|
}
|
|
>
|
|
<%= if @slideover_open do %>
|
|
<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"
|
|
>
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
<% else %>
|
|
<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"
|
|
>
|
|
<path d="m15 18-6-6 6-6" />
|
|
</svg>
|
|
<% end %>
|
|
</button>
|
|
|
|
<!-- Loading Indicator -->
|
|
<%= if @historical_loading do %>
|
|
<div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-[1000]
|
|
bg-white rounded-lg shadow-lg px-4 py-2 flex items-center space-x-2">
|
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
|
|
<span class="text-sm text-gray-600">
|
|
{gettext("Loading historical data...")}
|
|
<%= if @loading_batch && @total_batches do %>
|
|
({@loading_batch}/{@total_batches})
|
|
<% end %>
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Mobile Backdrop -->
|
|
<%= if @slideover_open do %>
|
|
<div class="fixed inset-0 bg-black bg-opacity-50 z-[999] lg:hidden" phx-click={toggle_slideover_js()}></div>
|
|
<% end %>
|
|
|
|
<!-- Control Panel Overlay -->
|
|
<div
|
|
id="slideover-panel"
|
|
class={[
|
|
"slideover-panel",
|
|
if(@slideover_open, do: "slideover-open", else: "slideover-closed")
|
|
]}
|
|
>
|
|
<!-- Header -->
|
|
<div class="bg-indigo-700 px-5 py-5 dark:bg-indigo-800">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2.5">
|
|
<svg class="h-5 w-5 text-indigo-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
|
/>
|
|
</svg>
|
|
<h2 class="text-base font-semibold text-white">APRS.me</h2>
|
|
</div>
|
|
<button
|
|
class="relative rounded-md text-indigo-200 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white lg:hidden"
|
|
phx-click={toggle_slideover_js()}
|
|
title={Gettext.gettext(AprsmeWeb.Gettext, "Close controls")}
|
|
>
|
|
<span class="absolute -inset-2.5"></span>
|
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
<path d="M6 18 18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<p class="mt-1 text-sm text-indigo-300 dark:text-indigo-200">
|
|
{gettext("Real-time APRS packet tracking")}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="relative flex-1 overflow-y-auto bg-white px-5 py-5 dark:bg-gray-800 dark:after:absolute dark:after:inset-y-0 dark:after:left-0 dark:after:w-px dark:after:bg-white/10">
|
|
<div class="space-y-5">
|
|
<!-- Callsign Search -->
|
|
<div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
|
|
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
<svg class="h-3.5 w-3.5 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
{gettext("Search Callsign")}
|
|
</label>
|
|
<form phx-submit="track_callsign" phx-change="track_callsign_form" id="track-callsign-form" class="space-y-2">
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="text"
|
|
name="callsign"
|
|
value={@tracked_callsign || @overlay_callsign}
|
|
phx-change="update_callsign"
|
|
placeholder={gettext("Enter callsign...")}
|
|
class="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 uppercase dark:bg-gray-800 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 whitespace-nowrap flex-shrink-0 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
|
>
|
|
{gettext("Track")}
|
|
</button>
|
|
</div>
|
|
<%= if @tracked_callsign != "" do %>
|
|
<div class="flex items-center justify-between rounded-md bg-indigo-50 px-3 py-2 dark:bg-indigo-500/10">
|
|
<span class="text-sm text-indigo-700 font-medium dark:text-indigo-300">
|
|
{gettext("Tracking:")} <span class="font-bold font-mono">{@tracked_callsign}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
phx-click="clear_tracking"
|
|
class="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-400 dark:hover:bg-indigo-500/20"
|
|
>
|
|
{gettext("Clear")}
|
|
</button>
|
|
</div>
|
|
<% end %>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Other SSIDs -->
|
|
<%= if @tracked_callsign != "" and @other_ssids != [] do %>
|
|
<div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
|
|
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
<svg class="h-3.5 w-3.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
|
/>
|
|
</svg>
|
|
{gettext("Other SSIDs")}
|
|
</label>
|
|
<div class="space-y-1">
|
|
<%= for ssid_info <- @other_ssids do %>
|
|
<div class="group flex items-center justify-between rounded-md bg-white px-3 py-2 text-sm ring-1 ring-gray-950/5 hover:ring-indigo-300 dark:bg-gray-800 dark:ring-white/10 dark:hover:ring-indigo-500/50">
|
|
<button
|
|
phx-click="track_callsign"
|
|
phx-value-callsign={ssid_info.callsign}
|
|
class="font-semibold font-mono text-indigo-600 group-hover:text-indigo-500 cursor-pointer dark:text-indigo-400 dark:group-hover:text-indigo-300"
|
|
>
|
|
{ssid_info.callsign}
|
|
</button>
|
|
<div class="flex items-center gap-2">
|
|
<%= if ssid_info.received_at do %>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
{time_ago_in_words(ssid_info.received_at)}
|
|
</span>
|
|
<% end %>
|
|
<.link
|
|
navigate={~p"/info/#{ssid_info.callsign}"}
|
|
class="text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400"
|
|
title={gettext("Station info")}
|
|
>
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Trail Duration -->
|
|
<div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
|
|
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
<svg class="h-3.5 w-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
{gettext("Trail Duration")}
|
|
</label>
|
|
<form id="trail-duration-form" phx-change="update_trail_duration">
|
|
<select
|
|
name="trail_duration"
|
|
class="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500"
|
|
>
|
|
<option value="1" selected={@trail_duration == "1"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 1)}
|
|
</option>
|
|
<option value="6" selected={@trail_duration == "6"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 6)}
|
|
</option>
|
|
<option value="12" selected={@trail_duration == "12"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 12)}
|
|
</option>
|
|
<option value="24" selected={@trail_duration == "24"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 24)}
|
|
</option>
|
|
<option value="48" selected={@trail_duration == "48"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 48)}
|
|
</option>
|
|
<option value="168" selected={@trail_duration == "168"}>
|
|
{gettext("1 Week")}
|
|
</option>
|
|
</select>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Historical Data -->
|
|
<div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
|
|
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
{gettext("Historical Data")}
|
|
</label>
|
|
<form id="historical-hours-form" phx-change="update_historical_hours">
|
|
<select
|
|
name="historical_hours"
|
|
class="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500"
|
|
>
|
|
<option value="1" selected={@historical_hours == "1"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 1)}
|
|
</option>
|
|
<option value="3" selected={@historical_hours == "3"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 3)}
|
|
</option>
|
|
<option value="6" selected={@historical_hours == "6"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 6)}
|
|
</option>
|
|
<option value="12" selected={@historical_hours == "12"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 12)}
|
|
</option>
|
|
<option value="24" selected={@historical_hours == "24"}>
|
|
{ngettext("1 Hour", "%{count} Hours", 24)}
|
|
</option>
|
|
</select>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<div class="border-t border-gray-200 pt-4 dark:border-white/10">
|
|
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
|
/>
|
|
</svg>
|
|
{gettext("Navigation")}
|
|
</label>
|
|
<nav class="space-y-1">
|
|
<.navigation
|
|
variant={:sidebar}
|
|
current_user={@current_user}
|
|
map_state={%{lat: @map_center.lat, lng: @map_center.lng, zoom: @map_zoom}}
|
|
tracked_callsign={@tracked_callsign}
|
|
/>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Status Footer -->
|
|
<div class="border-t border-gray-200 pt-4 dark:border-white/10">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{gettext("Last Update")}
|
|
</div>
|
|
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
|
<%= if @last_update_at do %>
|
|
<div class="font-mono font-medium">{time_ago_in_words(@last_update_at)}</div>
|
|
<div class="font-mono text-gray-400 dark:text-gray-500">
|
|
{Calendar.strftime(@last_update_at, "%H:%M UTC")}
|
|
</div>
|
|
<% else %>
|
|
<div class="font-mono text-gray-400">--</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{gettext("Last Deploy")}
|
|
</div>
|
|
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
|
<div class="font-mono font-medium">{time_ago_in_words(@deployed_at)}</div>
|
|
<div class="font-mono text-gray-400 dark:text-gray-500">
|
|
{Calendar.strftime(@deployed_at, "%H:%M UTC")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
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)
|
|
|
|
threshold = socket.assigns.packet_age_threshold
|
|
|
|
# Partition visible_packets into kept and expired
|
|
{kept, expired_keys} =
|
|
Enum.reduce(socket.assigns.visible_packets, {%{}, []}, fn {key, packet}, {kept_acc, expired_acc} ->
|
|
if SharedPacketUtils.packet_within_time_threshold?(packet, threshold) do
|
|
{Map.put(kept_acc, key, packet), expired_acc}
|
|
else
|
|
{kept_acc, [key | expired_acc]}
|
|
end
|
|
end)
|
|
|
|
socket = DisplayManager.remove_markers_batch(socket, expired_keys)
|
|
|
|
{:noreply, assign(socket, :visible_packets, kept)}
|
|
end
|
|
|
|
defp handle_reload_historical_packets(socket) do
|
|
Logger.debug(
|
|
"handle_reload_historical_packets called - map_ready: #{socket.assigns.map_ready}, map_bounds: #{inspect(socket.assigns.map_bounds)}"
|
|
)
|
|
|
|
if socket.assigns.map_ready and socket.assigns.map_bounds do
|
|
# Clear existing historical packets
|
|
socket = push_event(socket, "clear_historical_packets", %{})
|
|
|
|
# Start progressive loading using LiveView's efficient batching
|
|
socket = HistoricalLoader.start_progressive_historical_loading(socket)
|
|
|
|
{:noreply, socket}
|
|
else
|
|
Logger.debug("Skipping historical reload - conditions not met")
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
# Helper functions
|
|
|
|
# Fetch historical packets from the database
|
|
|
|
# Select the best packet to display for a callsign - prioritize position over weather
|
|
|
|
@spec filter_packets_by_time_and_bounds(map(), map(), DateTime.t()) :: map()
|
|
defp filter_packets_by_time_and_bounds(packets, bounds, time_threshold) do
|
|
packets
|
|
|> Enum.filter(fn {_callsign, packet} ->
|
|
BoundsUtils.within_bounds?(packet, bounds) &&
|
|
SharedPacketUtils.packet_within_time_threshold?(packet, time_threshold)
|
|
end)
|
|
|> Map.new()
|
|
end
|
|
|
|
@spec filter_packets_by_time_and_bounds_with_tracked(map(), map(), DateTime.t(), String.t(), map() | nil) :: map()
|
|
defp filter_packets_by_time_and_bounds_with_tracked(
|
|
packets,
|
|
bounds,
|
|
time_threshold,
|
|
tracked_callsign,
|
|
tracked_latest_packet
|
|
) do
|
|
filtered = filter_packets_by_time_and_bounds(packets, bounds, time_threshold)
|
|
|
|
# Always include the tracked callsign's latest packet if we have one
|
|
if tracked_callsign != "" and tracked_latest_packet do
|
|
key = get_callsign_key(tracked_latest_packet)
|
|
Map.put(filtered, key, tracked_latest_packet)
|
|
else
|
|
filtered
|
|
end
|
|
end
|
|
|
|
# Helper functions for marker operations
|
|
|
|
@spec add_markers_if_any(Socket.t(), list()) :: Socket.t()
|
|
defp add_markers_if_any(socket, []), do: socket
|
|
|
|
defp add_markers_if_any(socket, markers) do
|
|
push_event(socket, "add_markers", %{markers: markers})
|
|
end
|
|
|
|
defp preferred_tracked_packet(nil, packet), do: packet
|
|
|
|
defp preferred_tracked_packet(current_packet, incoming_packet) do
|
|
cond do
|
|
CoordinateUtils.has_position_data?(incoming_packet) ->
|
|
incoming_packet
|
|
|
|
CoordinateUtils.has_position_data?(current_packet) ->
|
|
current_packet
|
|
|
|
newer_packet?(incoming_packet, current_packet) ->
|
|
incoming_packet
|
|
|
|
true ->
|
|
current_packet
|
|
end
|
|
end
|
|
|
|
defp newer_packet?(left, right) do
|
|
case {packet_received_at(left), packet_received_at(right)} do
|
|
{%DateTime{} = left_dt, %DateTime{} = right_dt} ->
|
|
DateTime.after?(left_dt, right_dt)
|
|
|
|
{%NaiveDateTime{} = left_dt, %NaiveDateTime{} = right_dt} ->
|
|
NaiveDateTime.after?(left_dt, right_dt)
|
|
|
|
_ ->
|
|
false
|
|
end
|
|
end
|
|
|
|
defp packet_received_at(packet) do
|
|
Map.get(packet, :received_at) || Map.get(packet, "received_at")
|
|
end
|
|
|
|
@impl true
|
|
def terminate(_reason, socket) do
|
|
# Unregister from connection monitor
|
|
_ =
|
|
if Application.get_env(:aprsme, :cluster_enabled, false) and
|
|
not socket.assigns[:connection_draining] do
|
|
Aprsme.ConnectionMonitor.unregister_connection()
|
|
end
|
|
|
|
# Cleanup spatial registration if we have a client ID
|
|
_ =
|
|
if socket.assigns[:spatial_client_id] do
|
|
Aprsme.SpatialPubSub.unregister_client(socket.assigns.spatial_client_id)
|
|
end
|
|
|
|
# Unsubscribe from StreamingPacketsPubSub
|
|
_ = Aprsme.StreamingPacketsPubSub.unsubscribe(self())
|
|
|
|
# Unsubscribe from PubSub topic subscribed in mount
|
|
_ = Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
|
|
|
_ = if socket.assigns.buffer_timer, do: Process.cancel_timer(socket.assigns.buffer_timer)
|
|
|
|
# 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 pending hover-end timer — set when the mouse leaves a marker
|
|
# and only cleared when it re-enters; if the LV terminates between the two,
|
|
# the timer would otherwise outlive the socket.
|
|
_ =
|
|
if socket.assigns[:hover_end_timer] do
|
|
Process.cancel_timer(socket.assigns.hover_end_timer)
|
|
end
|
|
|
|
# Clean up any pending batch tasks
|
|
_ =
|
|
if socket.assigns[:pending_batch_tasks] do
|
|
Enum.each(socket.assigns.pending_batch_tasks, &Process.cancel_timer/1)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
# Bounds comparison now handled by shared utilities
|
|
|
|
# --- Private bounds update helpers ---
|
|
@spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()}
|
|
defp handle_bounds_update(bounds, socket) do
|
|
# Skip if we're currently loading historical data
|
|
if socket.assigns.historical_loading do
|
|
# Defer the bounds update
|
|
{:noreply, assign(socket, pending_bounds: bounds)}
|
|
else
|
|
# Update the map bounds from the client, converting to atom keys
|
|
map_bounds = BoundsUtils.normalize_bounds(bounds)
|
|
|
|
# Validate bounds to prevent invalid coordinates
|
|
if valid_bounds?(map_bounds) do
|
|
handle_valid_bounds_update(map_bounds, socket)
|
|
else
|
|
# Invalid bounds, skip update
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp valid_bounds?(map_bounds) do
|
|
map_bounds.north <= 90 and map_bounds.south >= -90 and map_bounds.north > map_bounds.south
|
|
end
|
|
|
|
defp handle_valid_bounds_update(map_bounds, socket) do
|
|
# Force processing if we need initial historical load, regardless of bounds comparison
|
|
cond do
|
|
socket.assigns[:needs_initial_historical_load] ->
|
|
Logger.debug("Processing initial bounds update immediately (forced): #{inspect(map_bounds)}")
|
|
socket = process_bounds_update(map_bounds, socket)
|
|
{:noreply, socket}
|
|
|
|
BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds) ->
|
|
{:noreply, socket}
|
|
|
|
true ->
|
|
# For subsequent updates, use the timer to debounce
|
|
schedule_bounds_update(map_bounds, socket)
|
|
end
|
|
end
|
|
|
|
# Configurable debounce delay (in milliseconds)
|
|
@bounds_update_debounce_ms Application.compile_env(:aprsme, :bounds_update_debounce_ms, 400)
|
|
|
|
defp schedule_bounds_update(map_bounds, socket) do
|
|
# Cancel existing timer if present
|
|
if socket.assigns[:bounds_update_timer] do
|
|
case Process.cancel_timer(socket.assigns.bounds_update_timer) do
|
|
false ->
|
|
# Timer already fired, receive the message to clear it
|
|
receive do
|
|
{:process_bounds_update, _} -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
_ ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
# Cancel any in-progress historical loading to prevent race conditions
|
|
socket = HistoricalLoader.cancel_pending_loads(socket)
|
|
|
|
# Use configurable debounce time for better stability during rapid zooming
|
|
timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, @bounds_update_debounce_ms)
|
|
socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp send_heat_map_data(socket, filtered_packets) do
|
|
# Convert map of packets to list
|
|
packet_list = Map.values(filtered_packets)
|
|
send_heat_map_for_packets(socket, packet_list)
|
|
end
|
|
|
|
# Common heat map display logic
|
|
defp send_heat_map_for_packets(socket, packets) do
|
|
# Get clustering data
|
|
case Clustering.cluster_packets(packets, socket.assigns.map_zoom) do
|
|
{:heat_map, heat_points} ->
|
|
push_event(socket, "show_heat_map", %{heat_points: heat_points})
|
|
|
|
{:raw_packets, _packets} ->
|
|
# Shouldn't happen at zoom <= 8, but handle it anyway
|
|
socket
|
|
end
|
|
end
|
|
|
|
@spec process_bounds_update(map(), Socket.t()) :: Socket.t()
|
|
defp process_bounds_update(map_bounds, socket) do
|
|
Logger.debug("process_bounds_update called with bounds: #{inspect(map_bounds)}")
|
|
|
|
sync_pubsub_bounds(socket, map_bounds)
|
|
|
|
bounds_state = bounds_update_state(socket, map_bounds)
|
|
log_bounds_update_state(bounds_state)
|
|
|
|
{new_visible_packets, packets_to_remove} = filtered_packets_for_bounds(socket.assigns.visible_packets, map_bounds)
|
|
|
|
socket =
|
|
socket
|
|
|> DisplayManager.remove_markers_batch(packets_to_remove)
|
|
|> maybe_clear_historical_packets(bounds_state)
|
|
|> push_event("filter_markers_by_bounds", %{bounds: map_bounds})
|
|
|> assign(map_bounds: map_bounds, visible_packets: new_visible_packets)
|
|
|> assign(needs_initial_historical_load: false)
|
|
|
|
log_historical_reload(socket)
|
|
|
|
socket = HistoricalLoader.start_progressive_historical_loading(socket)
|
|
|
|
# If tracking a callsign and at low zoom, send trail line instead of letting
|
|
# historical loader send heat map
|
|
socket =
|
|
if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do
|
|
DisplayManager.send_trail_line_for_tracked_callsign(socket)
|
|
else
|
|
socket
|
|
end
|
|
|
|
# Mark initial historical as completed if this was the initial load
|
|
if bounds_state.is_initial_load do
|
|
assign(socket, initial_historical_completed: true)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp sync_pubsub_bounds(socket, map_bounds) do
|
|
if socket.assigns[:spatial_client_id] do
|
|
Aprsme.SpatialPubSub.update_viewport(socket.assigns.spatial_client_id, map_bounds)
|
|
end
|
|
|
|
Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), map_bounds)
|
|
end
|
|
|
|
defp bounds_update_state(socket, map_bounds) do
|
|
%{
|
|
is_initial_load: socket.assigns[:needs_initial_historical_load] || !socket.assigns[:initial_bounds_loaded],
|
|
bounds_changed:
|
|
socket.assigns.map_bounds && not BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds),
|
|
initial_historical_completed: socket.assigns[:initial_historical_completed] || false
|
|
}
|
|
end
|
|
|
|
defp log_bounds_update_state(bounds_state) do
|
|
Logger.debug(
|
|
"is_initial_load: #{bounds_state.is_initial_load}, bounds_changed: #{bounds_state.bounds_changed}, " <>
|
|
"initial_historical_completed: #{bounds_state.initial_historical_completed}"
|
|
)
|
|
end
|
|
|
|
defp filtered_packets_for_bounds(visible_packets, map_bounds) do
|
|
{
|
|
BoundsUtils.filter_packets_by_bounds(visible_packets, map_bounds),
|
|
BoundsUtils.reject_packets_by_bounds(visible_packets, map_bounds)
|
|
}
|
|
end
|
|
|
|
defp maybe_clear_historical_packets(socket, %{
|
|
bounds_changed: true,
|
|
is_initial_load: false,
|
|
initial_historical_completed: true
|
|
}) do
|
|
Logger.debug("Bounds changed after initial load - clearing historical packets")
|
|
push_event(socket, "clear_historical_packets", %{})
|
|
end
|
|
|
|
defp maybe_clear_historical_packets(socket, _bounds_state) do
|
|
Logger.debug("Initial load or no significant change - keeping existing markers")
|
|
socket
|
|
end
|
|
|
|
defp log_historical_reload(socket) do
|
|
Logger.debug("Starting progressive historical loading for new bounds")
|
|
|
|
Logger.debug(
|
|
"Current assigns: historical_loading=#{socket.assigns.historical_loading}, map_bounds=#{inspect(socket.assigns.map_bounds)}"
|
|
)
|
|
end
|
|
end
|