aprs.me/lib/aprsme_web/live/map_live/bounds_updater.ex
Graham McIntire e1da1716b1
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 2s
refactor: split MapLive.Index into focused modules
- State (177 lines): mount assigns, defaults, slideover, connection status
- Events (540 lines): 22 handle_event handlers, rate limiting, URL helpers
- Subscriptions (122 lines): PubSub setup, spatial registration, cleanup
- BoundsUpdater (172 lines): bounds pipeline, validation, debouncing
- index.ex: 2,062 -> 1,202 lines (42% reduction)
- Credo: initial_historical_completed, bounds_update_timer added to ignored_assigns
  (consumed by extracted modules, invisible to credo)
- All 361 map_live tests pass across multiple random seeds
- Handoff: marked maintainability #1 as done
2026-07-26 15:30:08 -05:00

172 lines
5.7 KiB
Elixir

defmodule AprsmeWeb.MapLive.BoundsUpdater do
@moduledoc """
Handles bounds-change processing pipeline for the map LiveView.
Extracted from Index to reduce module size.
"""
import Phoenix.Component, only: [assign: 2]
import Phoenix.LiveView, only: [push_event: 3]
alias AprsmeWeb.Live.Shared.BoundsUtils
alias AprsmeWeb.MapLive.DisplayManager
alias AprsmeWeb.MapLive.HistoricalLoader
alias AprsmeWeb.MapLive.Subscriptions
alias Phoenix.LiveView.Socket
require Logger
@bounds_update_debounce_ms Application.compile_env(:aprsme, :bounds_update_debounce_ms, 400)
@doc """
Main entry point for bounds updates from client events.
Delegates to debounce pipeline or processes immediately.
"""
@spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()}
def handle_bounds_update(bounds, socket) do
if socket.assigns.historical_loading do
{:noreply, assign(socket, pending_bounds: bounds)}
else
map_bounds = BoundsUtils.normalize_bounds(bounds)
if valid_bounds?(map_bounds) do
handle_valid_bounds_update(map_bounds, socket)
else
{:noreply, socket}
end
end
end
@doc """
Process a pending bounds update from the debounce timer or from a forced initial load.
"""
@spec process_bounds_update(map(), Socket.t()) :: Socket.t()
def process_bounds_update(map_bounds, socket) do
Logger.debug("process_bounds_update called with bounds: #{inspect(map_bounds)}")
Subscriptions.update_viewport(socket, map_bounds)
bounds_state = bounds_update_state(socket, map_bounds)
log_bounds_update_state(bounds_state)
{new_visible_packets, packets_to_remove} = filtered_packets_for_bounds(socket.assigns.visible_packets, map_bounds)
socket =
socket
|> DisplayManager.remove_markers_batch(packets_to_remove)
|> maybe_clear_historical_packets(bounds_state)
|> push_event("filter_markers_by_bounds", %{bounds: map_bounds})
|> assign(map_bounds: map_bounds, visible_packets: new_visible_packets)
|> assign(needs_initial_historical_load: false)
log_historical_reload(socket)
socket = HistoricalLoader.start_progressive_historical_loading(socket)
# If tracking a callsign and at low zoom, send trail line instead of letting
# historical loader send heat map
socket =
if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do
DisplayManager.send_trail_line_for_tracked_callsign(socket)
else
socket
end
# Mark initial historical as completed if this was the initial load
if bounds_state.is_initial_load do
assign(socket, initial_historical_completed: true)
else
socket
end
end
@doc """
Schedule a debounced bounds update. Cancels any pending timer first.
"""
@spec schedule_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()}
def schedule_bounds_update(map_bounds, socket) do
if socket.assigns[:bounds_update_timer] do
case Process.cancel_timer(socket.assigns.bounds_update_timer) do
false ->
receive do
{:process_bounds_update, _} -> :ok
after
0 -> :ok
end
_ ->
:ok
end
end
socket = HistoricalLoader.cancel_pending_loads(socket)
timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, @bounds_update_debounce_ms)
{:noreply, assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds)}
end
# --- Private helpers ---
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
cond do
socket.assigns[:needs_initial_historical_load] ->
Logger.debug("Processing initial bounds update immediately (forced): #{inspect(map_bounds)}")
socket = process_bounds_update(map_bounds, socket)
{:noreply, socket}
BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds) ->
{:noreply, socket}
true ->
schedule_bounds_update(map_bounds, socket)
end
end
defp bounds_update_state(socket, map_bounds) do
%{
is_initial_load: socket.assigns[:needs_initial_historical_load] || !socket.assigns[:initial_bounds_loaded],
bounds_changed:
socket.assigns.map_bounds && not BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds),
initial_historical_completed: socket.assigns[:initial_historical_completed] || false
}
end
defp log_bounds_update_state(bounds_state) do
Logger.debug(
"is_initial_load: #{bounds_state.is_initial_load}, bounds_changed: #{bounds_state.bounds_changed}, " <>
"initial_historical_completed: #{bounds_state.initial_historical_completed}"
)
end
defp filtered_packets_for_bounds(visible_packets, map_bounds) do
{
BoundsUtils.filter_packets_by_bounds(visible_packets, map_bounds),
BoundsUtils.reject_packets_by_bounds(visible_packets, map_bounds)
}
end
defp maybe_clear_historical_packets(socket, %{
bounds_changed: true,
is_initial_load: false,
initial_historical_completed: true
}) do
Logger.debug("Bounds changed after initial load - clearing historical packets")
push_event(socket, "clear_historical_packets", %{})
end
defp maybe_clear_historical_packets(socket, _bounds_state) do
Logger.debug("Initial load or no significant change - keeping existing markers")
socket
end
defp log_historical_reload(socket) do
Logger.debug("Starting progressive historical loading for new bounds")
Logger.debug(
"Current assigns: historical_loading=#{socket.assigns.historical_loading}, map_bounds=#{inspect(socket.assigns.map_bounds)}"
)
end
end