When changing trail duration and historical data settings, historical positions would show up on the map but trail lines wouldn't connect them. This was caused by a mismatch between trail ID generation and callsign extraction. ## Changes - Fix getTrailId() to properly extract base callsigns from historical marker IDs - Add setTrailDuration() method to TrailManager for dynamic duration updates - Add LiveView event to synchronize trail duration changes with client - Add comprehensive tests for historical ID handling ## Problem Historical markers have IDs like "hist_CALLSIGN_123" but getTrailId() was returning the full ID while TrailManager.extractBaseCallsign() extracted just "CALLSIGN", creating separate trails instead of connected ones. ## Solution Updated getTrailId() to extract base callsigns from historical IDs using the same logic as TrailManager, ensuring all positions from the same station use the same trail ID and connect properly. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1468 lines
50 KiB
Elixir
1468 lines
50 KiB
Elixir
defmodule AprsmeWeb.MapLive.Index do
|
|
@moduledoc """
|
|
LiveView for displaying real-time APRS packets on a map
|
|
"""
|
|
use AprsmeWeb, :live_view
|
|
|
|
import AprsmeWeb.Components.ErrorBoundary
|
|
import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
|
|
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
|
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_patch: 2, put_flash: 3]
|
|
|
|
alias Aprsme.Packets.Clustering
|
|
alias AprsmeWeb.Endpoint
|
|
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.BoundsManager
|
|
alias AprsmeWeb.MapLive.DataBuilder
|
|
alias AprsmeWeb.MapLive.DisplayManager
|
|
alias AprsmeWeb.MapLive.HistoricalLoader
|
|
alias AprsmeWeb.MapLive.MapHelpers
|
|
alias AprsmeWeb.MapLive.Navigation
|
|
alias AprsmeWeb.MapLive.PacketManager
|
|
alias AprsmeWeb.MapLive.PacketProcessor
|
|
alias AprsmeWeb.MapLive.RfPath
|
|
alias AprsmeWeb.MapLive.UrlParams
|
|
alias AprsmeWeb.TimeUtils
|
|
alias Phoenix.LiveView.Socket
|
|
|
|
@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)
|
|
|
|
# Setup defaults
|
|
socket = assign_defaults(socket, one_hour_ago)
|
|
socket = assign(socket, initial_historical_completed: false)
|
|
|
|
# Setup additional subscriptions if connected
|
|
socket = setup_additional_subscriptions(socket)
|
|
|
|
# Handle callsign tracking
|
|
tracked_callsign = Map.get(params, "call", "")
|
|
|
|
{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 = BoundsManager.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
|
|
# Generate a unique client ID for this LiveView instance
|
|
client_id = "liveview_#{:erlang.phash2(self())}"
|
|
|
|
# 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
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, spatial_topic)
|
|
|
|
# Still subscribe to bad packets (they don't have location)
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets")
|
|
|
|
Process.send_after(self(), :cleanup_old_packets, 60_000)
|
|
|
|
socket
|
|
|> assign(:spatial_client_id, client_id)
|
|
|> assign(:spatial_topic, spatial_topic)
|
|
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
|
|
Endpoint.subscribe("aprs_messages")
|
|
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
|
|
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,
|
|
packet_state: PacketManager.init_packet_state(),
|
|
overlay_callsign: "",
|
|
tracked_callsign: tracked_callsign,
|
|
trail_duration: "1",
|
|
historical_hours: "1",
|
|
packet_age_threshold: one_hour_ago,
|
|
slideover_open: true,
|
|
deployed_at: deployed_at,
|
|
map_page: true,
|
|
packet_buffer: [],
|
|
buffer_timer: nil,
|
|
station_popup_open: false,
|
|
initial_bounds_loaded: false,
|
|
needs_initial_historical_load: tracked_callsign != "",
|
|
# Loading state management
|
|
historical_loading: false,
|
|
loading_generation: 0,
|
|
pending_batch_tasks: []
|
|
)
|
|
end
|
|
|
|
@spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t()
|
|
defp assign_defaults(socket, one_hour_ago) do
|
|
assign(socket,
|
|
packets: [],
|
|
all_packets: %{},
|
|
visible_packets: %{},
|
|
historical_packets: %{},
|
|
page_title: "APRS Map",
|
|
packet_state: PacketManager.init_packet_state(),
|
|
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: "",
|
|
trail_duration: "1",
|
|
historical_hours: "1",
|
|
# Slideover state - will be set based on screen size
|
|
slideover_open: 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
|
|
handle_bounds_update(bounds, socket)
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("locate_me", _params, socket) do
|
|
# Send JavaScript command to request browser geolocation
|
|
{:noreply, push_event(socket, "request_geolocation", %{})}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
|
|
# Update map center and zoom when location is received
|
|
# Parse and validate coordinates
|
|
lat_float = UrlParams.parse_latitude(lat)
|
|
lng_float = UrlParams.parse_longitude(lng)
|
|
|
|
# Additional validation - ensure we got valid coordinates
|
|
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
|
|
# Invalid coordinates - ignore the event
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_and_reload_markers", _params, socket) do
|
|
# Get current visible packets from PacketManager
|
|
current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state)
|
|
|
|
# Filter by time and bounds
|
|
filtered_packets =
|
|
filter_packets_by_time_and_bounds(
|
|
Map.new(current_packets, fn packet ->
|
|
{get_callsign_key(packet), packet}
|
|
end),
|
|
socket.assigns.map_bounds,
|
|
socket.assigns.packet_age_threshold
|
|
)
|
|
|
|
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
|
|
|
|
# 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")
|
|
|
|
{: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
|
|
|
|
# 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 length(path_station_positions) > 0 do
|
|
push_event(socket, "draw_rf_path", %{
|
|
station_lat: lat_float,
|
|
station_lng: lng_float,
|
|
path_stations: path_station_positions
|
|
})
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("marker_hover_end", _params, socket) do
|
|
# Clear the RF path lines
|
|
{:noreply, push_event(socket, "clear_rf_path", %{})}
|
|
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: "")
|
|
|> push_patch(to: "/")
|
|
else
|
|
# Set tracking
|
|
socket
|
|
|> assign(tracked_callsign: normalized_callsign)
|
|
|> push_patch(to: "/?call=#{normalized_callsign}")
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_tracking", _params, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(tracked_callsign: "", overlay_callsign: "")
|
|
|> push_patch(to: "/")
|
|
|
|
{: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})
|
|
|
|
# Trigger cleanup to remove packets that are now outside the new duration
|
|
send(self(), :cleanup_old_packets)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket) do
|
|
# Validate hours value
|
|
validated_hours = parse_historical_hours(hours)
|
|
socket = assign(socket, historical_hours: to_string(validated_hours))
|
|
|
|
# Trigger a reload of historical packets with the new time range
|
|
if socket.assigns.map_ready do
|
|
send(self(), :reload_historical_packets)
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search_callsign", %{"callsign" => callsign}, socket) do
|
|
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 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
|
|
|
|
new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}"
|
|
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)
|
|
|
|
# Update socket state
|
|
socket = assign(socket, map_center: map_center, map_zoom: map_zoom)
|
|
|
|
# If map is ready, update the client-side map
|
|
socket =
|
|
if socket.assigns.map_ready do
|
|
push_event(socket, "zoom_to_location", %{
|
|
lat: map_center.lat,
|
|
lng: map_center.lng,
|
|
zoom: map_zoom
|
|
})
|
|
else
|
|
socket
|
|
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)
|
|
|
|
@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: handle_info_postgres_packet(packet, socket)
|
|
|
|
def handle_info({:spatial_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket)
|
|
|
|
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(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket),
|
|
do: handle_info({:postgres_packet, packet}, socket)
|
|
|
|
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 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
|
|
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
|
|
|
|
# Handle replaying the next historical packet
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin=""
|
|
/>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
|
<script
|
|
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin=""
|
|
>
|
|
</script>
|
|
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js">
|
|
</script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js">
|
|
</script>
|
|
|
|
<style>
|
|
#aprs-map {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
bottom: 0;
|
|
height: 100vh;
|
|
z-index: 1;
|
|
transition: right 0.3s ease-in-out;
|
|
}
|
|
|
|
/* Desktop styles */
|
|
@media (min-width: 1024px) {
|
|
#aprs-map.slideover-open {
|
|
right: 352px;
|
|
}
|
|
|
|
#aprs-map.slideover-closed {
|
|
right: 0;
|
|
}
|
|
}
|
|
|
|
/* Mobile styles */
|
|
@media (max-width: 1023px) {
|
|
#aprs-map {
|
|
right: 0 !important;
|
|
}
|
|
}
|
|
|
|
.locate-button {
|
|
position: absolute;
|
|
left: 10px;
|
|
top: 80px;
|
|
z-index: 1000;
|
|
background: white;
|
|
border: 2px solid rgba(0,0,0,0.2);
|
|
border-radius: 4px;
|
|
padding: 5px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.locate-button:hover {
|
|
background: #f4f4f4;
|
|
}
|
|
|
|
.aprs-marker {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
}
|
|
|
|
.historical-marker {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.aprs-popup {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.aprs-callsign {
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
color: #1e40af;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.aprs-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;
|
|
}
|
|
</style>
|
|
|
|
<.error_boundary id="map-error-boundary">
|
|
<div
|
|
id="aprs-map"
|
|
class={if @slideover_open, do: "slideover-open", else: "slideover-closed"}
|
|
phx-hook="APRSMap"
|
|
phx-update="ignore"
|
|
data-center={Jason.encode!(@map_center)}
|
|
data-zoom={@map_zoom}
|
|
role="application"
|
|
aria-label={gettext("APRS packet map showing real-time amateur radio stations")}
|
|
>
|
|
</div>
|
|
</.error_boundary>
|
|
|
|
<button class="locate-button" phx-click="locate_me" title={Gettext.gettext(AprsmeWeb.Gettext, "Find my location")}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#374151" stroke="none">
|
|
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Slideover Toggle Button -->
|
|
<button
|
|
class={["slideover-toggle", if(@slideover_open, do: "slideover-open", else: "slideover-closed")]}
|
|
phx-click="toggle_slideover"
|
|
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 backdrop-blur-sm" phx-click="toggle_slideover">
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Control Panel Overlay -->
|
|
<div
|
|
id="slideover-panel"
|
|
class={[
|
|
"slideover-panel",
|
|
if(@slideover_open, do: "slideover-open", else: "slideover-closed")
|
|
]}
|
|
phx-hook="ResponsiveSlideoverHook"
|
|
>
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between p-6 border-b border-slate-200 bg-gradient-to-r from-indigo-600 to-purple-600 text-white">
|
|
<div class="flex items-center space-x-2">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
|
/>
|
|
</svg>
|
|
<h2 class="text-xl font-bold">APRS.me</h2>
|
|
</div>
|
|
|
|
<!-- Close button for mobile -->
|
|
<button
|
|
class="lg:hidden text-white hover:text-slate-200 transition-colors"
|
|
phx-click="toggle_slideover"
|
|
title={Gettext.gettext(AprsmeWeb.Gettext, "Close controls")}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="m18 6-12 12" />
|
|
<path d="m6 6 12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="p-6 space-y-6 bg-slate-50 flex-1 overflow-y-auto">
|
|
<!-- Callsign Search -->
|
|
<div class="space-y-4">
|
|
<label class="block text-sm font-semibold text-slate-700 flex items-center space-x-2">
|
|
<svg class="w-4 h-4 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>
|
|
<span>{gettext("Search Callsign")}</span>
|
|
</label>
|
|
<form phx-submit="track_callsign" class="flex flex-col space-y-2">
|
|
<div class="flex space-x-2">
|
|
<input
|
|
type="text"
|
|
name="callsign"
|
|
value={@tracked_callsign || @overlay_callsign}
|
|
phx-change="update_callsign"
|
|
placeholder={gettext("Enter callsign...")}
|
|
class="flex-1 px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm uppercase placeholder-slate-400 transition-all duration-200 hover:border-slate-400"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
class="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-xl hover:from-indigo-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-all duration-200 text-sm font-semibold shadow-lg hover:shadow-xl"
|
|
>
|
|
{gettext("Track")}
|
|
</button>
|
|
</div>
|
|
<%= if @tracked_callsign != "" do %>
|
|
<div class="flex items-center justify-between bg-blue-50 px-3 py-2 rounded-lg">
|
|
<span class="text-sm text-blue-700 font-medium">
|
|
{gettext("Tracking:")} <span class="font-bold">{@tracked_callsign}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
phx-click="clear_tracking"
|
|
class="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
|
>
|
|
{gettext("Clear")}
|
|
</button>
|
|
</div>
|
|
<% end %>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Trail Duration -->
|
|
<div class="space-y-4">
|
|
<label class="block text-sm font-semibold text-slate-700 flex items-center space-x-2">
|
|
<svg class="w-4 h-4 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>
|
|
<span>{gettext("Trail Duration")}</span>
|
|
</label>
|
|
<form phx-change="update_trail_duration" class="relative">
|
|
<select
|
|
name="trail_duration"
|
|
class="w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm bg-white text-gray-900 appearance-none transition-all duration-200 hover:border-slate-400"
|
|
>
|
|
<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>
|
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
|
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Historical Data -->
|
|
<div class="space-y-4">
|
|
<label class="block text-sm font-semibold text-slate-700 flex items-center space-x-2">
|
|
<svg class="w-4 h-4 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>
|
|
<span>{gettext("Historical Data")}</span>
|
|
</label>
|
|
<form phx-change="update_historical_hours" class="relative">
|
|
<select
|
|
name="historical_hours"
|
|
class="w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm bg-white text-gray-900 appearance-none transition-all duration-200 hover:border-slate-400"
|
|
>
|
|
<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>
|
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
|
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<div class="pt-4 border-t border-slate-200 space-y-3">
|
|
<div class="flex items-center space-x-2 text-sm text-slate-600 mb-3">
|
|
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
|
|
/>
|
|
</svg>
|
|
<span class="font-medium">{gettext("Navigation")}</span>
|
|
</div>
|
|
<.navigation
|
|
variant={:vertical}
|
|
class="text-sm"
|
|
current_user={@current_user}
|
|
map_state={%{lat: @map_center.lat, lng: @map_center.lng, zoom: @map_zoom}}
|
|
tracked_callsign={@tracked_callsign}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Deployment Information -->
|
|
<div class="pt-4 border-t border-slate-200 space-y-3">
|
|
<div class="flex items-center space-x-2 text-sm text-slate-600">
|
|
<svg class="w-4 h-4 text-slate-400" 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>
|
|
<span class="font-medium">{gettext("Last Deploy")}</span>
|
|
</div>
|
|
<div class="text-xs text-slate-500">
|
|
<div class="font-mono">{time_ago_in_words(@deployed_at)}</div>
|
|
<div class="font-mono text-slate-400">
|
|
{Calendar.strftime(@deployed_at, "%Y-%m-%d %H:%M UTC")}
|
|
</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)
|
|
|
|
# Use the current packet_age_threshold instead of hardcoded one hour
|
|
threshold = socket.assigns.packet_age_threshold
|
|
|
|
# Remove expired packets using PacketManager predicate
|
|
updated_packet_state =
|
|
PacketManager.remove_packets_where(socket.assigns.packet_state, fn packet ->
|
|
SharedPacketUtils.packet_within_time_threshold?(packet, threshold)
|
|
end)
|
|
|
|
# Get expired packet IDs that need to be removed from client
|
|
current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state)
|
|
remaining_packets = PacketManager.get_visible_packets(updated_packet_state)
|
|
|
|
expired_keys =
|
|
current_packets
|
|
|> Enum.reject(fn current_packet ->
|
|
Enum.any?(remaining_packets, fn remaining_packet ->
|
|
get_callsign_key(current_packet) == get_callsign_key(remaining_packet)
|
|
end)
|
|
end)
|
|
|> Enum.map(&get_callsign_key/1)
|
|
|
|
# Only update the client if there are expired markers
|
|
socket = DisplayManager.remove_markers_batch(socket, expired_keys)
|
|
|
|
{:noreply, assign(socket, packet_state: updated_packet_state)}
|
|
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 within_bounds?(map() | struct(), map()) :: boolean()
|
|
defp within_bounds?(packet, bounds) do
|
|
{lat, lon, _data_extended} = MapHelpers.get_coordinates(packet)
|
|
|
|
# Basic validation
|
|
check_coordinate_bounds(lat, lon, bounds)
|
|
end
|
|
|
|
defp check_coordinate_bounds(nil, _, _), do: false
|
|
defp check_coordinate_bounds(_, nil, _), do: false
|
|
|
|
defp check_coordinate_bounds(lat, lon, bounds) do
|
|
# Check latitude bounds (straightforward)
|
|
lat_in_bounds = lat >= bounds.south && lat <= bounds.north
|
|
|
|
# Check longitude bounds (handle potential wrapping)
|
|
lng_in_bounds = check_longitude_bounds(lon, bounds.west, bounds.east)
|
|
|
|
lat_in_bounds && lng_in_bounds
|
|
end
|
|
|
|
defp check_longitude_bounds(lon, west, east) when west <= east do
|
|
# Normal case: bounds don't cross antimeridian
|
|
lon >= west && lon <= east
|
|
end
|
|
|
|
defp check_longitude_bounds(lon, west, east) do
|
|
# Bounds cross antimeridian (e.g., west=170, east=-170)
|
|
lon >= west || lon <= east
|
|
end
|
|
|
|
# Helper functions to reduce duplicate filtering logic
|
|
|
|
@spec filter_packets_by_bounds(map(), map()) :: map()
|
|
defp filter_packets_by_bounds(packets_map, bounds) when is_map(packets_map) do
|
|
packets_map
|
|
|> Enum.filter(fn {_k, packet} -> within_bounds?(packet, bounds) end)
|
|
|> Map.new()
|
|
end
|
|
|
|
@spec filter_packets_by_bounds(list(), map()) :: list()
|
|
defp filter_packets_by_bounds(packets_list, bounds) when is_list(packets_list) do
|
|
Enum.filter(packets_list, &within_bounds?(&1, bounds))
|
|
end
|
|
|
|
@spec reject_packets_by_bounds(map(), map()) :: list()
|
|
defp reject_packets_by_bounds(packets_map, bounds) when is_map(packets_map) do
|
|
packets_map
|
|
|> Enum.reject(fn {_k, packet} -> within_bounds?(packet, bounds) end)
|
|
|> Enum.map(fn {k, _} -> k end)
|
|
end
|
|
|
|
@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} ->
|
|
within_bounds?(packet, bounds) &&
|
|
SharedPacketUtils.packet_within_time_threshold?(packet, time_threshold)
|
|
end)
|
|
|> Map.new()
|
|
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
|
|
|
|
@impl true
|
|
def terminate(_reason, socket) do
|
|
# 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
|
|
|
|
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 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 = MapHelpers.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
|
|
|
|
defp schedule_bounds_update(map_bounds, socket) do
|
|
# Cancel existing timer if present
|
|
if socket.assigns[:bounds_update_timer] do
|
|
Process.cancel_timer(socket.assigns.bounds_update_timer)
|
|
end
|
|
|
|
# Cancel any in-progress historical loading to prevent race conditions
|
|
socket = HistoricalLoader.cancel_pending_loads(socket)
|
|
|
|
# Increase debounce time slightly for better stability during rapid zooming
|
|
timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 400)
|
|
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)}")
|
|
|
|
# Update spatial viewport if we have a client ID
|
|
if socket.assigns[:spatial_client_id] do
|
|
Aprsme.SpatialPubSub.update_viewport(socket.assigns.spatial_client_id, map_bounds)
|
|
end
|
|
|
|
# Check if this is the initial load or if bounds have actually changed
|
|
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)
|
|
|
|
# Check if we've completed the initial historical load
|
|
initial_historical_completed = socket.assigns[:initial_historical_completed] || false
|
|
|
|
Logger.debug(
|
|
"is_initial_load: #{is_initial_load}, bounds_changed: #{bounds_changed}, initial_historical_completed: #{initial_historical_completed}"
|
|
)
|
|
|
|
# Remove out-of-bounds packets and markers immediately
|
|
current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state)
|
|
current_packets_map = Map.new(current_packets, fn packet -> {get_callsign_key(packet), packet} end)
|
|
|
|
new_visible_packets = filter_packets_by_bounds(current_packets_map, map_bounds)
|
|
packets_to_remove = reject_packets_by_bounds(current_packets_map, map_bounds)
|
|
|
|
# Remove markers for out-of-bounds packets
|
|
socket = remove_markers_batch(socket, packets_to_remove)
|
|
|
|
# Only clear historical packets if:
|
|
# 1. Bounds actually changed AND
|
|
# 2. This is not the initial load AND
|
|
# 3. We've already completed the initial historical load
|
|
socket =
|
|
if bounds_changed and not is_initial_load and initial_historical_completed do
|
|
Logger.debug("Bounds changed after initial load - clearing historical packets")
|
|
push_event(socket, "clear_historical_packets", %{})
|
|
else
|
|
Logger.debug("Initial load or no significant change - keeping existing markers")
|
|
socket
|
|
end
|
|
|
|
# Always filter markers by bounds
|
|
socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds})
|
|
|
|
# Update packet state to keep only packets within bounds
|
|
filtered_packets = Map.values(new_visible_packets)
|
|
{updated_packet_state, _} = PacketManager.add_visible_packets(socket.assigns.packet_state, filtered_packets)
|
|
|
|
# Update map bounds FIRST so progressive loading uses the correct bounds
|
|
socket =
|
|
socket
|
|
|> assign(map_bounds: map_bounds, packet_state: updated_packet_state)
|
|
|> assign(needs_initial_historical_load: false)
|
|
|
|
# Load historical packets for the new bounds (now socket.assigns.map_bounds is correct)
|
|
Logger.debug("Starting progressive historical loading for new bounds")
|
|
socket = HistoricalLoader.start_progressive_historical_loading(socket)
|
|
|
|
# Mark initial historical as completed if this was the initial load
|
|
if is_initial_load do
|
|
assign(socket, initial_historical_completed: true)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
end
|