Turned on :error_handling, :underspecs, and :unmatched_returns in mix.exs dialyzer config. The 97 warnings this surfaced were fixed in place rather than suppressed: - unmatched_return (79): explicit discard with `_ = ...` for fire-and-forget side effects (Process.cancel_timer, :ets.new, send/2), and pattern-matched `:ok = ...` for control-plane Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future return-shape change fails loud. - contract_supertype (18): tightened @spec arg and return types on data_builder, historical_loader, url_params, packet_utils, encoding_utils, aprs_symbol, weather_controller, packet_replay to match each function's actual success typing. No behavioural change. mix compile clean, 1008 tests pass, dialyzer count is now 0.
2106 lines
72 KiB
Elixir
2106 lines
72 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
|
|
|
|
@impl true
|
|
def mount(params, session, socket) do
|
|
socket = setup_subscriptions(socket)
|
|
|
|
# Basic setup
|
|
deployed_at = Aprsme.Release.deployed_at()
|
|
one_hour_ago = TimeUtils.one_day_ago()
|
|
|
|
# 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
|
|
"" -> ""
|
|
nil -> ""
|
|
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_hour_ago: one_hour_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)
|
|
# Schedule UI update timer to refresh "time ago" display every 30 seconds
|
|
_ = Process.send_after(self(), :update_time_display, 30_000)
|
|
|
|
socket
|
|
|> assign(:spatial_client_id, client_id)
|
|
|> assign(:spatial_topic, spatial_topic)
|
|
|> 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_hour_ago: one_hour_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_hour_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,
|
|
map_page: true,
|
|
packet_buffer: [],
|
|
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_hour_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_hour_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
|
|
require Logger
|
|
|
|
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
|
|
require Logger
|
|
|
|
# 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
|
|
require Logger
|
|
|
|
# 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
|
|
require Logger
|
|
|
|
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
|
|
require Logger
|
|
|
|
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
|
|
require Logger
|
|
|
|
Logger.debug("Skipping URL update on initial load")
|
|
assign(socket, should_skip_initial_url_update: false)
|
|
else
|
|
require Logger
|
|
|
|
# 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
|
|
require Logger
|
|
|
|
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(:update_time_display, socket), do: handle_update_time_display(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
|
|
|
|
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
|
|
require Logger
|
|
|
|
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
|
|
require Logger
|
|
|
|
# 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
|
|
|
|
defp update_display_for_batch(socket, marker_data_list) do
|
|
tracked_callsign = socket.assigns.tracked_callsign || ""
|
|
|
|
if socket.assigns.map_zoom <= 8 do
|
|
# If tracking a callsign, send trail line instead of heat map
|
|
if tracked_callsign == "" do
|
|
DisplayManager.send_heat_map_for_current_bounds(socket)
|
|
else
|
|
DisplayManager.send_trail_line_for_tracked_callsign(socket)
|
|
end
|
|
else
|
|
send_marker_batch(socket, marker_data_list)
|
|
end
|
|
end
|
|
|
|
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" 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 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 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_update_time_display(socket) do
|
|
# Schedule next update
|
|
Process.send_after(self(), :update_time_display, 30_000)
|
|
|
|
{:noreply, assign(socket, :time_display_tick, System.monotonic_time())}
|
|
end
|
|
|
|
defp handle_reload_historical_packets(socket) do
|
|
require Logger
|
|
|
|
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 remove_markers_batch(Socket.t(), list()) :: Socket.t()
|
|
defp remove_markers_batch(socket, []), do: socket
|
|
|
|
defp remove_markers_batch(socket, marker_ids) do
|
|
Enum.reduce(marker_ids, socket, fn id, acc ->
|
|
push_event(acc, "remove_marker", %{id: id})
|
|
end)
|
|
end
|
|
|
|
@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] ->
|
|
require Logger
|
|
|
|
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
|
|
require Logger
|
|
|
|
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
|
|
|> 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
|
|
require Logger
|
|
|
|
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
|
|
require Logger
|
|
|
|
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
|
|
require Logger
|
|
|
|
Logger.debug("Initial load or no significant change - keeping existing markers")
|
|
socket
|
|
end
|
|
|
|
defp log_historical_reload(socket) do
|
|
require Logger
|
|
|
|
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
|