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