- Added validation for trail_duration values (1, 6, 12, 24, 48, 168 hours) - Added validation for historical_hours values (1, 3, 6, 12, 24 hours) - Created parse_trail_duration and parse_historical_hours helper functions - Functions return safe defaults (1 hour) for invalid input - Prevents potential integer overflow from unchecked String.to_integer calls - Fixed all compilation warnings by properly grouping handle_event clauses 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2399 lines
79 KiB
Elixir
2399 lines
79 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.TimeHelpers, only: [time_ago_in_words: 1]
|
|
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2, put_flash: 3]
|
|
|
|
alias Aprsme.CachedQueries
|
|
alias Aprsme.GeoUtils
|
|
alias Aprsme.Packets.Clustering
|
|
alias AprsmeWeb.Endpoint
|
|
alias AprsmeWeb.MapLive.MapHelpers
|
|
alias AprsmeWeb.MapLive.PacketUtils
|
|
alias AprsmeWeb.MapLive.PopupComponent
|
|
alias AprsmeWeb.TimeUtils
|
|
alias Phoenix.HTML.Safe
|
|
alias Phoenix.LiveView.Socket
|
|
|
|
@default_center %{lat: 39.8283, lng: -98.5795}
|
|
@default_zoom 5
|
|
|
|
# Memory limits to prevent unbounded growth
|
|
@max_visible_packets 1000
|
|
@max_historical_packets 5000
|
|
@max_all_packets 2000
|
|
|
|
# Parse map state from URL parameters
|
|
@spec parse_map_params(map()) :: {map(), integer()}
|
|
defp parse_map_params(params) do
|
|
lat = parse_latitude(Map.get(params, "lat"))
|
|
lng = parse_longitude(Map.get(params, "lng"))
|
|
zoom = parse_zoom(Map.get(params, "z"))
|
|
|
|
map_center = %{lat: lat, lng: lng}
|
|
{map_center, zoom}
|
|
end
|
|
|
|
defp parse_latitude(nil), do: @default_center.lat
|
|
|
|
defp parse_latitude(lat_str) do
|
|
parse_float_in_range(lat_str, @default_center.lat, -90, 90)
|
|
end
|
|
|
|
defp parse_longitude(nil), do: @default_center.lng
|
|
|
|
defp parse_longitude(lng_str) do
|
|
parse_float_in_range(lng_str, @default_center.lng, -180, 180)
|
|
end
|
|
|
|
defp parse_zoom(nil), do: @default_zoom
|
|
|
|
defp parse_zoom(zoom_str) do
|
|
parse_int_in_range(zoom_str, @default_zoom, 1, 20)
|
|
end
|
|
|
|
defp parse_float_in_range(str, default, min, max) do
|
|
case Float.parse(str) do
|
|
{val, _} when val >= min and val <= max -> val
|
|
_ -> default
|
|
end
|
|
end
|
|
|
|
defp parse_int_in_range(str, default, min, max) do
|
|
case Integer.parse(str) do
|
|
{val, _} when val >= min and val <= max -> val
|
|
_ -> default
|
|
end
|
|
end
|
|
|
|
# Convert various types to float
|
|
defp to_float(value) when is_binary(value), do: String.to_float(value)
|
|
defp to_float(value) when is_integer(value), do: value / 1.0
|
|
defp to_float(value) when is_float(value), do: value
|
|
defp to_float(_), do: 0.0
|
|
|
|
@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} = 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} =
|
|
handle_callsign_tracking(
|
|
tracked_callsign,
|
|
map_center,
|
|
map_zoom,
|
|
has_explicit_url_params?(params)
|
|
)
|
|
|
|
# Calculate initial bounds
|
|
initial_bounds = 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
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets")
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets")
|
|
Process.send_after(self(), :cleanup_old_packets, 60_000)
|
|
socket
|
|
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 has_explicit_url_params?(params) do
|
|
!!(params["lat"] || params["lng"] || params["z"])
|
|
end
|
|
|
|
defp determine_map_location(params, session) do
|
|
{url_center, url_zoom} = parse_map_params(params)
|
|
has_explicit_url_params = has_explicit_url_params?(params)
|
|
|
|
case session["ip_geolocation"] do
|
|
%{"lat" => lat, "lng" => lng} when is_number(lat) and is_number(lng) ->
|
|
if has_explicit_url_params do
|
|
{url_center, url_zoom, false}
|
|
else
|
|
{%{lat: lat, lng: lng}, 11, true}
|
|
end
|
|
|
|
_ ->
|
|
{url_center, url_zoom, !has_explicit_url_params}
|
|
end
|
|
end
|
|
|
|
defp handle_callsign_tracking(tracked_callsign, map_center, map_zoom, has_explicit_url_params) do
|
|
if tracked_callsign != "" and not has_explicit_url_params do
|
|
case CachedQueries.get_latest_packet_for_callsign_cached(tracked_callsign) do
|
|
%{lat: lat, lon: lon} when is_number(lat) and is_number(lon) ->
|
|
{%{lat: lat, lng: lon}, 12}
|
|
|
|
_ ->
|
|
{map_center, map_zoom}
|
|
end
|
|
else
|
|
{map_center, map_zoom}
|
|
end
|
|
end
|
|
|
|
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,
|
|
visible_packets: %{},
|
|
historical_packets: %{},
|
|
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,
|
|
all_packets: %{},
|
|
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
|
|
|
|
# Calculate approximate bounds based on center point and zoom level
|
|
# This provides initial bounds for database queries before client sends actual bounds
|
|
@spec calculate_bounds_from_center_and_zoom(map(), integer()) :: map()
|
|
defp calculate_bounds_from_center_and_zoom(center, zoom) do
|
|
# Approximate degrees per pixel at different zoom levels
|
|
# These are rough estimates for initial bounds calculation
|
|
degrees_per_pixel = calculate_degrees_per_pixel(zoom)
|
|
|
|
# Assume viewport is roughly 800x600 pixels
|
|
# Half of 600px height
|
|
lat_offset = degrees_per_pixel * 300
|
|
# Half of 800px width
|
|
lng_offset = degrees_per_pixel * 400
|
|
|
|
%{
|
|
north: center.lat + lat_offset,
|
|
south: center.lat - lat_offset,
|
|
east: center.lng + lng_offset,
|
|
west: center.lng - lng_offset
|
|
}
|
|
end
|
|
|
|
@spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t()
|
|
defp assign_defaults(socket, one_hour_ago) do
|
|
assign(socket,
|
|
packets: [],
|
|
page_title: "APRS Map",
|
|
visible_packets: %{},
|
|
station_popup_open: false,
|
|
map_bounds: %{
|
|
north: 49.0,
|
|
south: 24.0,
|
|
east: -66.0,
|
|
west: -125.0
|
|
},
|
|
map_center: @default_center,
|
|
map_zoom: @default_zoom,
|
|
historical_packets: %{},
|
|
packet_age_threshold: one_hour_ago,
|
|
map_ready: false,
|
|
historical_loaded: false,
|
|
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
|
|
# Ensure coordinates are floats
|
|
lat_float = to_float(lat)
|
|
lng_float = to_float(lng)
|
|
|
|
socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_and_reload_markers", _params, socket) do
|
|
# Only filter the current visible_packets, do not re-query the database
|
|
filtered_packets =
|
|
filter_packets_by_time_and_bounds(
|
|
socket.assigns.visible_packets,
|
|
socket.assigns.map_bounds,
|
|
socket.assigns.packet_age_threshold
|
|
)
|
|
|
|
visible_packets_list = build_packet_data_list_from_map(filtered_packets, false, socket)
|
|
|
|
socket = assign(socket, visible_packets: filtered_packets)
|
|
|
|
# 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
|
|
socket =
|
|
if socket.assigns.map_center.lat == @default_center.lat and
|
|
socket.assigns.map_center.lng == @default_center.lng do
|
|
socket
|
|
else
|
|
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
|
|
|
|
Logger.info("RF path hover start - path: #{inspect(path)}")
|
|
|
|
# Parse the path to find digipeater/igate stations
|
|
path_stations = parse_rf_path(path)
|
|
Logger.info("Parsed path stations: #{inspect(path_stations)}")
|
|
|
|
# Query for positions of path stations
|
|
path_station_positions = get_path_station_positions(path_stations, socket)
|
|
Logger.info("Found #{length(path_station_positions)} station positions")
|
|
|
|
# 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,
|
|
station_lng: lng,
|
|
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)
|
|
|
|
# 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) do
|
|
lat = extract_coordinate(center, "lat", socket.assigns.map_center.lat)
|
|
lng = extract_coordinate(center, "lng", socket.assigns.map_center.lng)
|
|
|
|
# Validate and clamp values
|
|
lat = clamp_coordinate(lat, -90.0, 90.0)
|
|
lng = clamp_coordinate(lng, -180.0, 180.0)
|
|
|
|
{lat, lng}
|
|
end
|
|
|
|
defp extract_coordinate(map, key, default) do
|
|
Map.get(map, key, default)
|
|
end
|
|
|
|
defp clamp_coordinate(value, min, max) do
|
|
value |> max(min) |> min(max)
|
|
end
|
|
|
|
defp clamp_zoom(zoom) do
|
|
max(1, min(20, zoom))
|
|
end
|
|
|
|
# Calculate degrees per pixel based on zoom level
|
|
defp calculate_degrees_per_pixel(zoom) when zoom >= 15, do: 0.000005
|
|
defp calculate_degrees_per_pixel(zoom) when zoom >= 12, do: 0.00005
|
|
defp calculate_degrees_per_pixel(zoom) when zoom >= 10, do: 0.0002
|
|
defp calculate_degrees_per_pixel(zoom) when zoom >= 8, do: 0.001
|
|
defp calculate_degrees_per_pixel(zoom) when zoom >= 6, do: 0.005
|
|
defp calculate_degrees_per_pixel(zoom) when zoom >= 4, do: 0.02
|
|
defp calculate_degrees_per_pixel(_), do: 0.1
|
|
|
|
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
|
|
(old_zoom <= 8 and new_zoom > 8) or (old_zoom > 8 and new_zoom <= 8)
|
|
end
|
|
|
|
defp handle_zoom_threshold_crossing(socket, zoom) do
|
|
if zoom <= 8 do
|
|
# Switching to heat map
|
|
socket
|
|
|> push_event("clear_all_markers", %{})
|
|
|> send_heat_map_for_current_bounds()
|
|
else
|
|
# Switching to markers
|
|
trigger_marker_display(socket)
|
|
end
|
|
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("", socket), do: {:noreply, socket}
|
|
|
|
defp handle_callsign_search(callsign, socket) do
|
|
if valid_callsign?(callsign) do
|
|
{:noreply, push_navigate(socket, to: "/#{callsign}")}
|
|
else
|
|
{:noreply, put_flash(socket, :error, gettext("Invalid callsign format"))}
|
|
end
|
|
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} = 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) when is_binary(duration) do
|
|
case Integer.parse(duration) do
|
|
{hours, ""} when hours in [1, 6, 12, 24, 48, 168] ->
|
|
hours
|
|
_ ->
|
|
# Default to 1 hour if invalid
|
|
1
|
|
end
|
|
end
|
|
|
|
defp parse_trail_duration(_), do: 1
|
|
|
|
# Parse historical hours with validation
|
|
defp parse_historical_hours(hours) when is_binary(hours) do
|
|
case Integer.parse(hours) do
|
|
{h, ""} when h in [1, 3, 6, 12, 24] ->
|
|
h
|
|
_ ->
|
|
# Default to 1 hour if invalid
|
|
1
|
|
end
|
|
end
|
|
|
|
defp parse_historical_hours(_), do: 1
|
|
|
|
@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({: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 = 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 = 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 we need to process this bounds update
|
|
should_process =
|
|
!socket.assigns[:initial_bounds_loaded] or
|
|
socket.assigns[:needs_initial_historical_load] or
|
|
not 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
|
|
|
|
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 = 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
|
|
{lat, lon, _data_extended} = MapHelpers.get_coordinates(packet)
|
|
callsign_key = get_callsign_key(packet)
|
|
|
|
# Update all_packets with memory limit
|
|
all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet)
|
|
|
|
all_packets =
|
|
if map_size(all_packets) > @max_all_packets do
|
|
prune_oldest_packets(all_packets, @max_all_packets)
|
|
else
|
|
all_packets
|
|
end
|
|
|
|
socket = assign(socket, all_packets: all_packets)
|
|
|
|
# Handle packet visibility logic
|
|
socket = handle_packet_visibility(packet, lat, lon, callsign_key, socket)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp get_callsign_key(packet) do
|
|
if Map.has_key?(packet, "id"),
|
|
do: to_string(packet["id"]),
|
|
else: System.unique_integer([:positive])
|
|
end
|
|
|
|
defp handle_packet_visibility(packet, lat, lon, callsign_key, socket) do
|
|
cond do
|
|
should_remove_marker?(lat, lon, callsign_key, socket) ->
|
|
remove_marker_from_map(callsign_key, socket)
|
|
|
|
should_add_marker?(lat, lon, callsign_key, socket) ->
|
|
handle_valid_postgres_packet(packet, lat, lon, socket)
|
|
|
|
should_update_marker?(lat, lon, callsign_key, socket) ->
|
|
# Marker exists and is within bounds - check if there's significant movement
|
|
existing_packet = socket.assigns.visible_packets[callsign_key]
|
|
{existing_lat, existing_lon, _} = MapHelpers.get_coordinates(existing_packet)
|
|
|
|
# Check if we have valid existing coordinates
|
|
if is_number(existing_lat) and is_number(existing_lon) and
|
|
GeoUtils.significant_movement?(existing_lat, existing_lon, lat, lon, 15) do
|
|
# Significant movement detected (more than 15 meters), update the marker
|
|
handle_valid_postgres_packet(packet, lat, lon, socket)
|
|
else
|
|
# Just GPS drift or invalid coordinates, update the packet data but don't send visual update
|
|
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
|
|
socket = assign(socket, visible_packets: new_visible_packets)
|
|
socket
|
|
end
|
|
|
|
true ->
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp should_remove_marker?(lat, lon, callsign_key, socket) do
|
|
!is_nil(lat) and !is_nil(lon) and
|
|
Map.has_key?(socket.assigns.visible_packets, callsign_key) and
|
|
not MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds)
|
|
end
|
|
|
|
defp should_add_marker?(lat, lon, callsign_key, socket) do
|
|
!is_nil(lat) and !is_nil(lon) and
|
|
not Map.has_key?(socket.assigns.visible_packets, callsign_key) and
|
|
MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds)
|
|
end
|
|
|
|
defp should_update_marker?(lat, lon, callsign_key, socket) do
|
|
!is_nil(lat) and !is_nil(lon) and
|
|
Map.has_key?(socket.assigns.visible_packets, callsign_key) and
|
|
MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds)
|
|
end
|
|
|
|
defp remove_marker_from_map(callsign_key, socket) do
|
|
socket = push_event(socket, "remove_marker", %{id: callsign_key})
|
|
new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key)
|
|
assign(socket, visible_packets: new_visible_packets)
|
|
end
|
|
|
|
defp handle_valid_postgres_packet(packet, _lat, _lon, socket) do
|
|
# Add the packet to visible_packets and push a marker immediately
|
|
callsign_key =
|
|
if Map.has_key?(packet, "id"),
|
|
do: to_string(packet["id"]),
|
|
else: System.unique_integer([:positive])
|
|
|
|
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
|
|
|
|
# Enforce memory limits
|
|
new_visible_packets =
|
|
if map_size(new_visible_packets) > @max_visible_packets do
|
|
prune_oldest_packets(new_visible_packets, @max_visible_packets)
|
|
else
|
|
new_visible_packets
|
|
end
|
|
|
|
socket = assign(socket, visible_packets: new_visible_packets)
|
|
|
|
# Check zoom level to decide how to display the packet
|
|
if socket.assigns.map_zoom <= 8 do
|
|
# We're in heat map mode - update the heat map with all current data
|
|
send_heat_map_for_current_bounds(socket)
|
|
else
|
|
# We're in marker mode - send individual marker
|
|
marker_data = PacketUtils.build_packet_data(packet, true, get_locale(socket))
|
|
|
|
if marker_data do
|
|
# Only show new packet popup if no station popup is currently open
|
|
if socket.assigns.station_popup_open do
|
|
# Send without opening popup to avoid interrupting user
|
|
push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false))
|
|
else
|
|
push_event(socket, "new_packet", marker_data)
|
|
end
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
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 from visible_packets
|
|
expired_keys =
|
|
socket.assigns.visible_packets
|
|
|> Enum.filter(fn {_key, packet} ->
|
|
not packet_within_time_threshold?(packet, threshold)
|
|
end)
|
|
|> Enum.map(fn {key, _} -> key end)
|
|
|
|
# Only update the client if there are expired markers
|
|
socket = remove_markers_batch(socket, expired_keys)
|
|
|
|
# Use Map.drop/2 for better performance
|
|
updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys)
|
|
{:noreply, assign(socket, visible_packets: updated_visible_packets)}
|
|
end
|
|
|
|
defp handle_reload_historical_packets(socket) do
|
|
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 = start_progressive_historical_loading(socket)
|
|
|
|
{:noreply, socket}
|
|
else
|
|
Logger.debug("Skipping historical reload - conditions not met")
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
# Check if a packet is within the time threshold (not too old)
|
|
@spec packet_within_time_threshold?(struct(), any()) :: boolean()
|
|
defp packet_within_time_threshold?(packet, threshold) do
|
|
case packet do
|
|
%{received_at: received_at} when not is_nil(received_at) ->
|
|
threshold_dt = convert_threshold_to_datetime(threshold)
|
|
DateTime.compare(received_at, threshold_dt) in [:gt, :eq]
|
|
|
|
_ ->
|
|
# If no timestamp, treat as current
|
|
true
|
|
end
|
|
end
|
|
|
|
defp convert_threshold_to_datetime(threshold) when is_integer(threshold) do
|
|
# Assume seconds since epoch
|
|
DateTime.from_unix!(threshold)
|
|
end
|
|
|
|
defp convert_threshold_to_datetime(threshold) when is_binary(threshold) do
|
|
case DateTime.from_iso8601(threshold) do
|
|
{:ok, dt, _} -> dt
|
|
_ -> DateTime.utc_now()
|
|
end
|
|
end
|
|
|
|
defp convert_threshold_to_datetime(%DateTime{} = threshold), do: threshold
|
|
defp convert_threshold_to_datetime(_), do: DateTime.utc_now()
|
|
|
|
# Helper functions
|
|
|
|
# Fetch historical packets from the database
|
|
|
|
# Select the best packet to display for a callsign - prioritize position over weather
|
|
defp select_best_packet_for_display(packets) do
|
|
# Separate position and weather packets using the same logic as PacketUtils.weather_packet?
|
|
{position_packets, weather_packets} =
|
|
Enum.split_with(packets, fn packet ->
|
|
# A packet is a position packet if it's NOT a weather packet
|
|
not PacketUtils.weather_packet?(packet)
|
|
end)
|
|
|
|
# Prefer the most recent position packet, fall back to most recent weather packet
|
|
case position_packets do
|
|
[] ->
|
|
# No position packets, use most recent weather packet
|
|
hd(weather_packets)
|
|
|
|
[single_position] ->
|
|
# Only one position packet, use it
|
|
single_position
|
|
|
|
position_list ->
|
|
# Multiple position packets, use most recent one
|
|
Enum.max_by(position_list, & &1.received_at, DateTime)
|
|
end
|
|
end
|
|
|
|
defp build_packet_data_list(historical_packets) do
|
|
# Include weather data in initial grouping to avoid separate query
|
|
grouped_packets =
|
|
Enum.group_by(historical_packets, fn packet ->
|
|
packet.sender || "unknown"
|
|
end)
|
|
|
|
# Build weather callsign set from packets themselves (no DB query needed)
|
|
weather_callsigns = build_weather_callsign_set(historical_packets)
|
|
|
|
# For each callsign group, find the most recent packet and mark it appropriately
|
|
grouped_packets
|
|
|> Enum.flat_map(fn {callsign, packets} ->
|
|
# Sort by received_at to find most recent
|
|
sorted_packets = Enum.sort_by(packets, & &1.received_at, {:desc, DateTime})
|
|
|
|
case sorted_packets do
|
|
[] ->
|
|
[]
|
|
|
|
packets_list ->
|
|
# Find the best packet to display as "current" - prioritize position over weather
|
|
selected_packet = select_best_packet_for_display(packets_list)
|
|
historical = Enum.reject(packets_list, &(&1.id == selected_packet.id))
|
|
|
|
# Always include the selected packet
|
|
has_weather = MapSet.member?(weather_callsigns, String.upcase(callsign))
|
|
most_recent_data = build_minimal_packet_data(selected_packet, true, has_weather)
|
|
|
|
# Get coordinates of selected packet for distance filtering
|
|
{most_recent_lat, most_recent_lon, _} = MapHelpers.get_coordinates(selected_packet)
|
|
|
|
# Filter historical packets that are too close to most recent position
|
|
filtered_historical =
|
|
if most_recent_lat && most_recent_lon do
|
|
Enum.filter(historical, fn packet ->
|
|
{lat, lon, _} = MapHelpers.get_coordinates(packet)
|
|
|
|
if lat && lon do
|
|
distance_meters = calculate_distance_meters(most_recent_lat, most_recent_lon, lat, lon)
|
|
# Only show if 10+ meters away
|
|
distance_meters >= 10.0
|
|
else
|
|
# Skip packets without coordinates
|
|
false
|
|
end
|
|
end)
|
|
else
|
|
# If most recent has no coordinates, include all historical
|
|
historical
|
|
end
|
|
|
|
# Build data for remaining historical packets
|
|
historical_data =
|
|
filtered_historical
|
|
|> Enum.map(fn packet -> build_minimal_packet_data(packet, false, has_weather) end)
|
|
|> Enum.filter(& &1)
|
|
|
|
# Combine most recent and filtered historical
|
|
Enum.filter([most_recent_data | historical_data], & &1)
|
|
end
|
|
end)
|
|
|> Enum.filter(& &1)
|
|
end
|
|
|
|
defp build_minimal_packet_data(packet, is_most_recent, has_weather) do
|
|
# Build minimal packet data without calling expensive PacketUtils.build_packet_data
|
|
{lat, lon, _} = MapHelpers.get_coordinates(packet)
|
|
|
|
if lat && lon do
|
|
# Use PacketUtils to get symbol information properly (includes data_extended fallback)
|
|
symbol_table_id = PacketUtils.get_packet_field(packet, :symbol_table_id, "/")
|
|
symbol_code = PacketUtils.get_packet_field(packet, :symbol_code, ">")
|
|
|
|
# Generate symbol HTML using the SymbolRenderer
|
|
symbol_html =
|
|
AprsmeWeb.SymbolRenderer.render_marker_symbol(
|
|
symbol_table_id,
|
|
symbol_code,
|
|
packet.sender || "",
|
|
32
|
|
)
|
|
|
|
%{
|
|
"id" => if(is_most_recent, do: "current_#{packet.id}", else: "hist_#{packet.id}"),
|
|
"lat" => lat,
|
|
"lng" => lon,
|
|
"callsign" => packet.sender || "",
|
|
"symbol_table_id" => symbol_table_id,
|
|
"symbol_code" => symbol_code,
|
|
"symbol_html" => symbol_html,
|
|
"comment" => packet.comment || "",
|
|
"timestamp" => DateTime.to_unix(packet.received_at || DateTime.utc_now(), :millisecond),
|
|
"historical" => !is_most_recent,
|
|
"is_most_recent_for_callsign" => is_most_recent,
|
|
"path" => packet.path || "",
|
|
"popup" => build_simple_popup(packet, has_weather)
|
|
}
|
|
end
|
|
end
|
|
|
|
defp build_simple_popup(packet, has_weather) do
|
|
# Build popup HTML directly without database queries
|
|
callsign = packet.sender || "Unknown"
|
|
timestamp_dt = packet.received_at || DateTime.utc_now()
|
|
cache_buster = System.system_time(:millisecond)
|
|
|
|
# Check if this packet itself is a weather packet
|
|
is_weather = PacketUtils.weather_packet?(packet)
|
|
|
|
if is_weather do
|
|
# Build weather popup
|
|
%{
|
|
callsign: callsign,
|
|
comment: nil,
|
|
timestamp_dt: timestamp_dt,
|
|
cache_buster: cache_buster,
|
|
weather: true,
|
|
weather_link: true,
|
|
temperature: PacketUtils.get_weather_field(packet, :temperature),
|
|
temp_unit: "°F",
|
|
humidity: PacketUtils.get_weather_field(packet, :humidity),
|
|
wind_direction: PacketUtils.get_weather_field(packet, :wind_direction),
|
|
wind_speed: PacketUtils.get_weather_field(packet, :wind_speed),
|
|
wind_unit: "mph",
|
|
wind_gust: PacketUtils.get_weather_field(packet, :wind_gust),
|
|
gust_unit: "mph",
|
|
pressure: PacketUtils.get_weather_field(packet, :pressure),
|
|
rain_1h: PacketUtils.get_weather_field(packet, :rain_1h),
|
|
rain_24h: PacketUtils.get_weather_field(packet, :rain_24h),
|
|
rain_since_midnight: PacketUtils.get_weather_field(packet, :rain_since_midnight),
|
|
rain_1h_unit: "in",
|
|
rain_24h_unit: "in",
|
|
rain_since_midnight_unit: "in"
|
|
}
|
|
|> PopupComponent.popup()
|
|
|> Safe.to_iodata()
|
|
|> IO.iodata_to_binary()
|
|
else
|
|
# Build standard popup
|
|
%{
|
|
callsign: callsign,
|
|
comment: packet.comment || "",
|
|
timestamp_dt: timestamp_dt,
|
|
cache_buster: cache_buster,
|
|
weather: false,
|
|
# Use pre-fetched weather info
|
|
weather_link: has_weather
|
|
}
|
|
|> PopupComponent.popup()
|
|
|> Safe.to_iodata()
|
|
|> IO.iodata_to_binary()
|
|
end
|
|
end
|
|
|
|
# Build weather callsign set from packets themselves (avoids DB query)
|
|
defp build_weather_callsign_set(packets) do
|
|
packets
|
|
|> Enum.filter(&PacketUtils.weather_packet?/1)
|
|
|> MapSet.new(fn packet -> String.upcase(packet.sender || "") end)
|
|
end
|
|
|
|
# Calculate distance between two lat/lon points in meters using Haversine formula
|
|
defp calculate_distance_meters(lat1, lon1, lat2, lon2) do
|
|
# Convert latitude and longitude from degrees to radians
|
|
lat1_rad = lat1 * :math.pi() / 180
|
|
lon1_rad = lon1 * :math.pi() / 180
|
|
lat2_rad = lat2 * :math.pi() / 180
|
|
lon2_rad = lon2 * :math.pi() / 180
|
|
|
|
# Haversine formula
|
|
dlat = lat2_rad - lat1_rad
|
|
dlon = lon2_rad - lon1_rad
|
|
|
|
a =
|
|
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
|
|
:math.cos(lat1_rad) * :math.cos(lat2_rad) *
|
|
:math.sin(dlon / 2) * :math.sin(dlon / 2)
|
|
|
|
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
|
|
|
|
# Earth's radius in meters
|
|
earth_radius_meters = 6_371_000
|
|
|
|
# Distance in meters
|
|
earth_radius_meters * c
|
|
end
|
|
|
|
# Progressive loading functions using LiveView's efficient update mechanisms
|
|
@spec start_progressive_historical_loading(Socket.t()) :: Socket.t()
|
|
defp start_progressive_historical_loading(socket) do
|
|
require Logger
|
|
|
|
Logger.debug(
|
|
"start_progressive_historical_loading called with zoom: #{socket.assigns.map_zoom}, bounds: #{inspect(socket.assigns.map_bounds)}"
|
|
)
|
|
|
|
# Increment generation to invalidate any in-flight loads
|
|
new_generation = socket.assigns.loading_generation + 1
|
|
|
|
# Cancel any pending batch tasks
|
|
socket = cancel_pending_loads(socket)
|
|
|
|
# For high zoom levels, load everything in one batch for maximum speed
|
|
zoom = socket.assigns.map_zoom || 5
|
|
|
|
if zoom >= 10 do
|
|
# High zoom - load everything at once for maximum speed
|
|
Logger.debug("High zoom (#{zoom}), loading in single batch")
|
|
|
|
socket
|
|
|> assign(
|
|
loading_batch: 0,
|
|
total_batches: 1,
|
|
historical_loading: true,
|
|
loading_generation: new_generation
|
|
)
|
|
|> load_historical_batch(0, new_generation)
|
|
else
|
|
# Low zoom - use progressive loading to prevent overwhelming
|
|
total_batches = calculate_batch_count_for_zoom(zoom)
|
|
|
|
# Start with first batch
|
|
socket =
|
|
socket
|
|
|> assign(
|
|
loading_batch: 0,
|
|
total_batches: total_batches,
|
|
historical_loading: true,
|
|
loading_generation: new_generation
|
|
)
|
|
|> load_historical_batch(0, new_generation)
|
|
|
|
# Schedule remaining batches with generation check
|
|
batch_refs =
|
|
Enum.map(1..(total_batches - 1), fn batch_index ->
|
|
Process.send_after(self(), {:load_historical_batch, batch_index, new_generation}, batch_index * 50)
|
|
end)
|
|
|
|
socket = assign(socket, pending_batch_tasks: batch_refs)
|
|
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp cancel_pending_loads(socket) do
|
|
# Cancel any pending batch load messages
|
|
Enum.each(socket.assigns.pending_batch_tasks, &Process.cancel_timer/1)
|
|
assign(socket, pending_batch_tasks: [])
|
|
end
|
|
|
|
# Consolidated zoom-based loading parameters
|
|
@spec get_loading_params_for_zoom(integer()) :: {batch_size :: integer(), batch_count :: integer()}
|
|
# Very zoomed in - load everything at once, minimal batches
|
|
defp get_loading_params_for_zoom(zoom) when zoom >= 15, do: {500, 2}
|
|
# Moderately zoomed in
|
|
defp get_loading_params_for_zoom(zoom) when zoom >= 12, do: {500, 3}
|
|
# High zoom - still load a lot
|
|
defp get_loading_params_for_zoom(zoom) when zoom >= 10, do: {500, 4}
|
|
# Medium zoom
|
|
defp get_loading_params_for_zoom(zoom) when zoom >= 8, do: {100, 4}
|
|
# Zoomed out
|
|
defp get_loading_params_for_zoom(zoom) when zoom >= 5, do: {75, 5}
|
|
# Very zoomed out - smaller batches, more of them
|
|
defp get_loading_params_for_zoom(_), do: {50, 5}
|
|
|
|
@spec calculate_batch_size_for_zoom(integer()) :: integer()
|
|
defp calculate_batch_size_for_zoom(zoom) do
|
|
{batch_size, _} = get_loading_params_for_zoom(zoom)
|
|
batch_size
|
|
end
|
|
|
|
@spec calculate_batch_count_for_zoom(integer()) :: integer()
|
|
defp calculate_batch_count_for_zoom(zoom) do
|
|
{_, batch_count} = get_loading_params_for_zoom(zoom)
|
|
batch_count
|
|
end
|
|
|
|
@spec load_historical_batch(Socket.t(), integer(), integer() | nil) :: Socket.t()
|
|
defp load_historical_batch(socket, batch_offset, generation) do
|
|
# Check generation if provided
|
|
if generation && generation != socket.assigns.loading_generation do
|
|
# Stale request, return unchanged socket
|
|
socket
|
|
else
|
|
do_load_historical_batch(socket, batch_offset)
|
|
end
|
|
end
|
|
|
|
defp do_load_historical_batch(socket, batch_offset) do
|
|
if socket.assigns.map_bounds do
|
|
bounds = [
|
|
socket.assigns.map_bounds.west,
|
|
socket.assigns.map_bounds.south,
|
|
socket.assigns.map_bounds.east,
|
|
socket.assigns.map_bounds.north
|
|
]
|
|
|
|
# Calculate zoom-based batch size - higher zoom = smaller batches for faster response
|
|
zoom = socket.assigns.map_zoom || 5
|
|
batch_size = calculate_batch_size_for_zoom(zoom)
|
|
offset = batch_offset * batch_size
|
|
|
|
packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets)
|
|
|
|
historical_packets =
|
|
try do
|
|
if packets_module == Aprsme.Packets do
|
|
# Use cached queries for better performance
|
|
# Include zoom level in cache key for better cache efficiency
|
|
params = %{
|
|
bounds: bounds,
|
|
limit: batch_size,
|
|
offset: offset,
|
|
zoom: zoom
|
|
}
|
|
|
|
# Add callsign filter if tracking
|
|
params =
|
|
if socket.assigns.tracked_callsign == "" do
|
|
params
|
|
else
|
|
Map.put(params, :callsign, socket.assigns.tracked_callsign)
|
|
end
|
|
|
|
# Use the historical_hours setting from the UI dropdown with validation
|
|
historical_hours = parse_historical_hours(socket.assigns.historical_hours || "1")
|
|
params = Map.put(params, :hours_back, historical_hours)
|
|
|
|
Aprsme.CachedQueries.get_recent_packets_cached(params)
|
|
else
|
|
# Fallback for testing
|
|
packets_module.get_recent_packets_optimized(%{
|
|
bounds: bounds,
|
|
limit: batch_size,
|
|
offset: offset
|
|
})
|
|
end
|
|
rescue
|
|
error ->
|
|
require Logger
|
|
|
|
Logger.error("Error loading historical packets: #{inspect(error)}")
|
|
# Return empty list and notify user
|
|
send(self(), {:show_error, gettext("Failed to load historical data. Please try again.")})
|
|
[]
|
|
end
|
|
|
|
if Enum.any?(historical_packets) do
|
|
# Process this batch and send to frontend
|
|
packet_data_list =
|
|
try do
|
|
build_packet_data_list(historical_packets)
|
|
rescue
|
|
e ->
|
|
require Logger
|
|
|
|
Logger.error("Error building packet data list: #{inspect(e)}")
|
|
[]
|
|
end
|
|
|
|
if Enum.any?(packet_data_list) do
|
|
# Check zoom level to decide between heat map and markers
|
|
total_batches = socket.assigns.total_batches || 4
|
|
is_final_batch = batch_offset >= total_batches - 1
|
|
|
|
socket =
|
|
if socket.assigns.map_zoom <= 8 do
|
|
# For heat maps, store historical packets and update heat map when all batches are loaded
|
|
# Add packets to historical_packets assign
|
|
new_historical =
|
|
Enum.reduce(historical_packets, socket.assigns.historical_packets, fn packet, acc ->
|
|
key = if Map.has_key?(packet, :id), do: to_string(packet.id), else: to_string(packet["id"])
|
|
Map.put(acc, key, packet)
|
|
end)
|
|
|
|
# Apply memory limit
|
|
new_historical =
|
|
if map_size(new_historical) > @max_historical_packets do
|
|
prune_oldest_packets(new_historical, @max_historical_packets)
|
|
else
|
|
new_historical
|
|
end
|
|
|
|
socket = assign(socket, historical_packets: new_historical)
|
|
|
|
# If this is the final batch, update the heat map
|
|
if is_final_batch do
|
|
send_heat_map_for_current_bounds(socket)
|
|
else
|
|
socket
|
|
end
|
|
else
|
|
# Use LiveView's efficient push_event for incremental updates
|
|
push_event(socket, "add_historical_packets_batch", %{
|
|
packets: packet_data_list,
|
|
batch: batch_offset,
|
|
is_final: is_final_batch
|
|
})
|
|
end
|
|
|
|
# Update progress for user feedback
|
|
socket = assign(socket, loading_batch: batch_offset + 1)
|
|
|
|
# Mark loading as complete if this was the final batch
|
|
socket =
|
|
if is_final_batch do
|
|
assign(socket, historical_loading: false)
|
|
else
|
|
socket
|
|
end
|
|
|
|
# Process any pending bounds update if loading is complete
|
|
if is_final_batch && socket.assigns.pending_bounds do
|
|
send(self(), {:process_pending_bounds})
|
|
end
|
|
|
|
socket
|
|
else
|
|
socket
|
|
end
|
|
else
|
|
socket
|
|
end
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
@spec within_bounds?(map() | struct(), map()) :: boolean()
|
|
defp within_bounds?(packet, bounds) do
|
|
{lat, lon, _data_extended} = MapHelpers.get_coordinates(packet)
|
|
|
|
# Basic validation
|
|
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) &&
|
|
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
|
|
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
|
|
|
|
# Add a helper for robust bounds comparison
|
|
|
|
defp compare_bounds(nil, nil), do: true
|
|
defp compare_bounds(nil, _), do: false
|
|
defp compare_bounds(_, nil), do: false
|
|
defp compare_bounds(b1, b2), do: compare_bounds_maps(b1, b2)
|
|
|
|
defp compare_bounds_maps(b1, b2) do
|
|
Enum.all?([:north, :south, :east, :west], fn key ->
|
|
round_to_4_places(Map.get(b1, key)) == round_to_4_places(Map.get(b2, key))
|
|
end)
|
|
end
|
|
|
|
defp round_to_4_places(n) when is_float(n), do: Float.round(n, 4)
|
|
defp round_to_4_places(n) when is_integer(n), do: n * 1.0
|
|
defp round_to_4_places(x), do: x
|
|
|
|
# --- 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}
|
|
|
|
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
|
|
if socket.assigns[:bounds_update_timer] do
|
|
Process.cancel_timer(socket.assigns.bounds_update_timer)
|
|
end
|
|
|
|
timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 300)
|
|
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
|
|
|
|
defp send_heat_map_for_current_bounds(socket) do
|
|
# Get all packets within current bounds
|
|
all_packets =
|
|
Map.values(socket.assigns.visible_packets) ++
|
|
Map.values(socket.assigns.historical_packets)
|
|
|
|
# Filter by bounds
|
|
filtered_packets =
|
|
all_packets
|
|
|> filter_packets_by_bounds(socket.assigns.map_bounds)
|
|
|> Enum.uniq_by(fn packet ->
|
|
Map.get(packet, :id) || Map.get(packet, "id")
|
|
end)
|
|
|
|
send_heat_map_for_packets(socket, filtered_packets)
|
|
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
|
|
|
|
# Helper to build packet data list from a map of packets
|
|
defp build_packet_data_list_from_map(packets_map, is_most_recent, socket) do
|
|
locale = get_locale(socket)
|
|
|
|
packets_map
|
|
|> Enum.map(fn {_callsign, packet} ->
|
|
PacketUtils.build_packet_data(packet, is_most_recent, locale)
|
|
end)
|
|
|> Enum.filter(& &1)
|
|
end
|
|
|
|
# Helper to get locale from socket
|
|
defp get_locale(socket) do
|
|
Map.get(socket.assigns, :locale, "en")
|
|
end
|
|
|
|
# Helper to update map center and zoom to location
|
|
defp update_and_zoom_to_location(socket, lat, lng, zoom) do
|
|
socket
|
|
|> assign(map_center: %{lat: lat, lng: lng}, map_zoom: zoom)
|
|
|> push_event("zoom_to_location", %{lat: lat, lng: lng, zoom: zoom})
|
|
end
|
|
|
|
# Helper to zoom to current location
|
|
defp zoom_to_current_location(socket) do
|
|
push_event(socket, "zoom_to_location", %{
|
|
lat: socket.assigns.map_center.lat,
|
|
lng: socket.assigns.map_center.lng,
|
|
zoom: socket.assigns.map_zoom
|
|
})
|
|
end
|
|
|
|
defp trigger_marker_display(socket) do
|
|
# Clear heat map and show markers
|
|
socket = push_event(socket, "show_markers", %{})
|
|
|
|
# Re-send all visible packets as markers
|
|
visible_packets_list = build_packet_data_list_from_map(socket.assigns.visible_packets, true, socket)
|
|
|
|
socket = add_markers_if_any(socket, visible_packets_list)
|
|
|
|
# Trigger historical packet reload for markers
|
|
start_progressive_historical_loading(socket)
|
|
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)}")
|
|
|
|
# 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 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
|
|
new_visible_packets = filter_packets_by_bounds(socket.assigns.visible_packets, map_bounds)
|
|
packets_to_remove = reject_packets_by_bounds(socket.assigns.visible_packets, 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 map bounds FIRST so progressive loading uses the correct bounds
|
|
socket =
|
|
socket
|
|
|> assign(map_bounds: map_bounds, visible_packets: new_visible_packets)
|
|
|> 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 = 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
|
|
|
|
# Parse RF path string to extract digipeater/igate callsigns
|
|
defp parse_rf_path(path) when is_binary(path) do
|
|
# Split by comma and filter out empty strings
|
|
path
|
|
|> String.split(",")
|
|
|> Enum.map(&String.trim/1)
|
|
# Skip empty strings, TCPIP entries and qA* entries
|
|
|> Enum.reject(fn callsign ->
|
|
callsign == "" || String.contains?(callsign, "TCPIP") || String.starts_with?(callsign, "qA")
|
|
end)
|
|
|> Enum.map(fn callsign ->
|
|
# Remove any asterisk (used to mark heard stations) and WIDE patterns
|
|
callsign
|
|
|> String.replace("*", "")
|
|
|> String.trim()
|
|
end)
|
|
# Filter out APRS built-in beacon patterns
|
|
|> Enum.reject(fn callsign ->
|
|
callsign == "" || aprs_beacon?(callsign)
|
|
end)
|
|
|> Enum.uniq()
|
|
end
|
|
|
|
defp parse_rf_path(_), do: []
|
|
|
|
# Get positions of path stations from database with caching
|
|
defp get_path_station_positions(callsigns, _socket) when is_list(callsigns) do
|
|
# Use cached queries for better performance
|
|
CachedQueries.get_path_station_positions_cached(callsigns)
|
|
end
|
|
|
|
defp get_path_station_positions(_, _), do: []
|
|
|
|
# Memory management helpers
|
|
defp prune_oldest_packets(packets_map, max_size) when map_size(packets_map) > max_size do
|
|
# Convert to list and sort by received_at timestamp
|
|
sorted_packets =
|
|
packets_map
|
|
|> Enum.sort_by(
|
|
fn {_id, packet} ->
|
|
get_packet_timestamp(packet)
|
|
end,
|
|
{:desc, DateTime}
|
|
)
|
|
|> Enum.take(max_size)
|
|
|> Map.new()
|
|
|
|
sorted_packets
|
|
end
|
|
|
|
defp prune_oldest_packets(packets_map, _max_size), do: packets_map
|
|
|
|
defp get_packet_timestamp(%{received_at: timestamp}), do: timestamp
|
|
defp get_packet_timestamp(%{"received_at" => timestamp}), do: timestamp
|
|
defp get_packet_timestamp(_), do: DateTime.utc_now()
|
|
|
|
# Validate callsign format for security
|
|
defp valid_callsign?(callsign) do
|
|
# APRS callsign pattern: 1-2 letters, 1 digit, 0-3 letters, optional -SSID
|
|
Regex.match?(~r/^[A-Z]{1,2}\d[A-Z]{0,3}(-\d{1,2})?$/, callsign)
|
|
end
|
|
|
|
# Check if a callsign is an APRS built-in beacon pattern
|
|
@spec aprs_beacon?(String.t()) :: boolean()
|
|
defp aprs_beacon?(callsign) do
|
|
# Common APRS beacon patterns to exclude
|
|
patterns = [
|
|
# WIDE1, WIDE2, WIDE1-1, etc.
|
|
~r/^WIDE\d(-\d)?$/i,
|
|
# TRACE patterns
|
|
~r/^TRACE\d(-\d)?$/i,
|
|
# RELAY
|
|
~r/^RELAY$/i,
|
|
# ECHO
|
|
~r/^ECHO$/i,
|
|
# GATE
|
|
~r/^GATE$/i,
|
|
# UNPROTO
|
|
~r/^UNPROTO$/i,
|
|
# BEACON
|
|
~r/^BEACON$/i,
|
|
# CQ
|
|
~r/^CQ$/i,
|
|
# QTH
|
|
~r/^QTH$/i,
|
|
# MAIL
|
|
~r/^MAIL$/i,
|
|
# TEMP1, TEMP2, etc.
|
|
~r/^TEMP\d+$/i,
|
|
# ID
|
|
~r/^ID$/i,
|
|
# GPS
|
|
~r/^GPS$/i,
|
|
# DF
|
|
~r/^DF$/i,
|
|
# DGPS
|
|
~r/^DGPS$/i,
|
|
# PHG patterns
|
|
~r/^PHG\d+$/i,
|
|
# RNG patterns
|
|
~r/^RNG\d+$/i,
|
|
# DFS patterns
|
|
~r/^DFS\d+$/i,
|
|
# HOP patterns
|
|
~r/^HOP\d+(-\d)?$/i,
|
|
# REGION patterns
|
|
~r/^REGION\d+(-\d)?$/i,
|
|
# STATE patterns
|
|
~r/^STATE\d+(-\d)?$/i
|
|
]
|
|
|
|
# Check if the callsign matches any beacon pattern
|
|
Enum.any?(patterns, fn pattern ->
|
|
Regex.match?(pattern, callsign)
|
|
end)
|
|
end
|
|
end
|