defmodule AprsmeWeb.MapLive.Events do @moduledoc """ Handles all LiveView client events (handle_event/3) for the map view. Extracted from Index to reduce module size. For bounds-related events, delegates back to the message loop in Index; the bounds pipeline will be extracted to BoundsUpdater in Phase 4. """ import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1] import Phoenix.Component, only: [assign: 2, assign: 3] import Phoenix.LiveView, only: [push_event: 3, push_patch: 2] alias Aprsme.Packets alias Aprsme.Packets.Clustering alias AprsmeWeb.Live.Shared.BoundsUtils alias AprsmeWeb.Live.Shared.CoordinateUtils alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils alias AprsmeWeb.Live.Shared.ParamUtils alias AprsmeWeb.MapLive.DataBuilder alias AprsmeWeb.MapLive.DisplayManager alias AprsmeWeb.MapLive.Navigation alias AprsmeWeb.MapLive.RfPath alias AprsmeWeb.MapLive.UrlParams alias AprsmeWeb.Plugs.RateLimiter, as: RateLimiterPlug alias Phoenix.LiveView.Socket require Logger @event_rate_scale 60_000 # --------------------------------------------------------------------------- # Public event handlers — each corresponds to a handle_event/3 clause in Index # --------------------------------------------------------------------------- @doc """ Handle bounds_changed or update_bounds events. Delegates to the bounds update pipeline which lives in Index until Phase 4. """ @spec handle_bounds_changed(map(), Socket.t()) :: {:noreply, Socket.t()} def handle_bounds_changed(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 send(self(), {:process_bounds_update, map_bounds}) end {:noreply, socket} 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 @spec handle_locate_me(Socket.t()) :: {:noreply, Socket.t()} def handle_locate_me(socket) do {:noreply, push_event(socket, "request_geolocation", %{})} end @spec handle_set_location(number() | String.t(), number() | String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_set_location(lat, lng, socket) do lat_float = to_float(lat) lng_float = to_float(lng) if UrlParams.valid_coordinates?(lat_float, lng_float) do socket = Navigation.update_and_zoom_to_location(socket, lat_float, lng_float, 12) {:noreply, socket} else {:noreply, socket} end end @spec handle_clear_and_reload_markers(Socket.t()) :: {:noreply, Socket.t()} def handle_clear_and_reload_markers(socket) do filtered_packets = filter_packets_by_time_and_bounds_with_tracked( socket.assigns.visible_packets, socket.assigns.map_bounds, socket.assigns.packet_age_threshold, socket.assigns.tracked_callsign, socket.assigns.tracked_callsign_latest_packet ) visible_packets_list = DataBuilder.build_packet_data_list_from_map(filtered_packets, false, socket) socket = if socket.assigns.map_zoom <= 8 do send_heat_map_data(socket, filtered_packets) else add_markers_if_any(socket, visible_packets_list) end {:noreply, socket} end @spec handle_map_ready(Socket.t()) :: {:noreply, Socket.t()} def handle_map_ready(socket) do socket = assign(socket, map_ready: true) 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 socket = if socket.assigns.tracked_callsign != "" and socket.assigns.tracked_callsign_latest_packet do packet = socket.assigns.tracked_callsign_latest_packet packet_data = DataBuilder.build_packet_data(packet) push_event(socket, "add_historical_packets_batch", %{ packets: [packet_data], batch: 0, is_final: false }) else socket end socket = if socket.assigns.needs_initial_historical_load and socket.assigns.map_bounds do send(self(), {:process_bounds_update, socket.assigns.map_bounds}) socket else socket end {:noreply, socket} end @spec handle_marker_clicked(Socket.t()) :: {:noreply, Socket.t()} def handle_marker_clicked(socket) do {:noreply, assign(socket, station_popup_open: true)} end @spec handle_marker_hover_start(String.t(), String.t(), number(), number(), Socket.t()) :: {:noreply, Socket.t()} def handle_marker_hover_start(_id, path, lat, lng, socket) do socket = if socket.assigns[:hover_end_timer] do _ = Process.cancel_timer(socket.assigns.hover_end_timer) assign(socket, hover_end_timer: nil) else socket end 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) safe_path = ParamUtils.sanitize_path_string(path) path_stations = RfPath.parse_rf_path(safe_path) path_station_positions = RfPath.get_path_station_positions(path_stations, socket) socket = if path_station_positions == [] do socket else push_event(socket, "draw_rf_path", %{ station_lat: lat_float, station_lng: lng_float, path_stations: path_station_positions }) end {:noreply, socket} end @spec handle_marker_hover_end(Socket.t()) :: {:noreply, Socket.t()} def handle_marker_hover_end(socket) do timer = Process.send_after(self(), :clear_rf_path, 100) {:noreply, assign(socket, hover_end_timer: timer)} end @spec handle_update_callsign(String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_update_callsign(callsign, socket) do {:noreply, assign(socket, overlay_callsign: callsign)} end @spec handle_track_callsign(String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_track_callsign(callsign, socket) do case check_event_rate(socket, "track_callsign", 20) do :ok -> normalized_callsign = String.upcase(String.trim(callsign)) socket = if normalized_callsign == "" do socket |> assign(tracked_callsign: "", other_ssids: []) |> update_url_with_current_state() else other_ssids = Packets.get_other_ssids(normalized_callsign) latest_packet = Packets.get_latest_packet_for_callsign(normalized_callsign) socket = assign(socket, tracked_callsign: normalized_callsign, other_ssids: other_ssids, tracked_callsign_latest_packet: latest_packet ) socket = zoom_to_latest_packet(socket, latest_packet) push_patch(socket, to: "/#{normalized_callsign}") end {:noreply, socket} {:deny, _retry_after} -> {:noreply, socket} end end @spec handle_clear_tracking(Socket.t()) :: {:noreply, Socket.t()} def handle_clear_tracking(socket) do socket = socket |> assign(tracked_callsign: "", overlay_callsign: "", other_ssids: []) |> push_event("clear_trail_line", %{}) |> update_url_with_current_state() {:noreply, socket} end @spec handle_update_trail_duration(String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_update_trail_duration(duration, socket) do case check_event_rate(socket, "update_trail_duration", 20) do :ok -> hours = SharedPacketUtils.parse_trail_duration(duration) new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold) socket = push_event(socket, "update_trail_duration", %{duration_hours: hours}) socket = update_url_with_current_state(socket) send(self(), :cleanup_old_packets) socket = if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do DisplayManager.send_trail_line_for_tracked_callsign(socket) else socket end {:noreply, socket} {:deny, _retry_after} -> {:noreply, socket} end end @spec handle_update_historical_hours(String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_update_historical_hours(hours, socket) do case check_event_rate(socket, "update_historical_hours", 20) do :ok -> validated_hours = SharedPacketUtils.parse_historical_hours(hours) socket = assign(socket, historical_hours: to_string(validated_hours)) socket = update_url_with_current_state(socket) if socket.assigns.map_ready do send(self(), :reload_historical_packets) end {:noreply, socket} {:deny, _retry_after} -> {:noreply, socket} end end @spec handle_search_callsign(String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_search_callsign(callsign, socket) do case check_event_rate(socket, "search_callsign", 30) do :ok -> callsign |> String.trim() |> String.upcase() |> Navigation.handle_callsign_search(socket) {:deny, _retry_after} -> {:noreply, socket} end end @spec handle_toggle_slideover(Socket.t()) :: {:noreply, Socket.t()} def handle_toggle_slideover(socket) do {:noreply, assign(socket, slideover_open: !socket.assigns.slideover_open)} end @spec handle_set_slideover_state(boolean(), Socket.t()) :: {:noreply, Socket.t()} def handle_set_slideover_state(open, socket) do {:noreply, assign(socket, slideover_open: open)} end @spec handle_geolocation_error(String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_geolocation_error(_error, socket) do {:noreply, socket} end @spec handle_request_geolocation(Socket.t()) :: {:noreply, Socket.t()} def handle_request_geolocation(socket) do {:noreply, socket} end @spec handle_popup_closed(Socket.t()) :: {:noreply, Socket.t()} def handle_popup_closed(socket) do {:noreply, assign(socket, station_popup_open: false)} end @spec handle_get_assigns(Socket.t()) :: {:noreply, Socket.t()} def handle_get_assigns(socket) do send(self(), {:test_assigns, socket.assigns}) {:noreply, socket} end @spec handle_update_map_state(map(), map(), map(), Socket.t()) :: {:noreply, Socket.t()} def handle_update_map_state(center, zoom, params, socket) do {lat, lng} = parse_center_coordinates(center, socket) zoom = clamp_zoom(zoom) map_center = %{lat: lat, lng: lng} socket = update_map_state(socket, map_center, zoom) socket = handle_url_update(socket, lat, lng, zoom) socket = process_bounds_from_params(socket, params) {:noreply, socket} end @spec handle_error_boundary_triggered(String.t(), String.t(), String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_error_boundary_triggered(message, stack, component_id, socket) do Logger.error("Error boundary triggered in component #{component_id}: #{message}\n#{stack}") {:noreply, socket} end # --------------------------------------------------------------------------- # Rate limiting helpers # --------------------------------------------------------------------------- @doc """ Store client IP in socket assigns for rate-limiting. """ @spec store_client_ip(Socket.t()) :: Socket.t() def store_client_ip(socket) do ip = RateLimiterPlug.extract_socket_ip(socket) assign(socket, :client_ip, ip) end @doc """ Check whether an event handler should be rate-limited. """ @spec check_event_rate(Socket.t(), String.t(), integer()) :: :ok | {:deny, integer()} def check_event_rate(socket, event_name, limit) do ip = Map.get(socket.assigns, :client_ip, "unknown") key = "lv_event:#{event_name}:#{ip}" case RateLimiterPlug.check_rate_limit(key, @event_rate_scale, limit) do {:allow, _count} -> :ok {:deny, retry_after} -> Logger.warning("LiveView event rate-limited: event=#{event_name} ip=#{ip}") {:deny, retry_after} end end # --------------------------------------------------------------------------- # URL state helpers # --------------------------------------------------------------------------- @doc """ Update URL with current map state, trail duration, and historical hours. """ @spec update_url_with_current_state(Socket.t()) :: Socket.t() def update_url_with_current_state(socket) do lat = socket.assigns.map_center.lat lng = socket.assigns.map_center.lng zoom = socket.assigns.map_zoom handle_url_update(socket, lat, lng, zoom) end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- 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 = DisplayManager.crossing_zoom_threshold?(old_zoom, zoom) socket = assign(socket, map_center: map_center, map_zoom: zoom) if crossing_threshold do DisplayManager.handle_zoom_threshold_crossing(socket, zoom) else 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 assign(socket, should_skip_initial_url_update: false) else trail_param = if socket.assigns[:trail_duration] && socket.assigns[:trail_duration] != "1", do: "&trail=#{socket.assigns[:trail_duration]}", else: "" hist_param = if socket.assigns[:historical_hours] && socket.assigns[:historical_hours] != "1", do: "&hist=#{socket.assigns[:historical_hours]}", else: "" base_path = if socket.assigns.tracked_callsign == "" do "/" else "/#{socket.assigns.tracked_callsign}" end new_path = "#{base_path}?lat=#{lat}&lng=#{lng}&z=#{zoom}#{trail_param}#{hist_param}" 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 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 to_float(val) when is_float(val), do: val defp to_float(val) when is_integer(val), do: val / 1 defp to_float(val) when is_binary(val) do case Float.parse(val) do {f, _} -> f :error -> nil end end defp to_float(_), do: nil defp filter_packets_by_time_and_bounds(packets, bounds, time_threshold) do packets |> Enum.filter(fn {_callsign, packet} -> BoundsUtils.within_bounds?(packet, bounds) && SharedPacketUtils.packet_within_time_threshold?(packet, time_threshold) end) |> Map.new() end defp filter_packets_by_time_and_bounds_with_tracked( packets, bounds, time_threshold, tracked_callsign, tracked_latest_packet ) do filtered = filter_packets_by_time_and_bounds(packets, bounds, time_threshold) if tracked_callsign != "" and tracked_latest_packet do key = get_callsign_key(tracked_latest_packet) Map.put(filtered, key, tracked_latest_packet) else filtered end end defp add_markers_if_any(socket, []), do: socket defp add_markers_if_any(socket, markers) do push_event(socket, "add_markers", %{markers: markers}) end defp send_heat_map_data(socket, filtered_packets) do packet_list = Map.values(filtered_packets) case Clustering.cluster_packets(packet_list, socket.assigns.map_zoom) do {:heat_map, heat_points} -> push_event(socket, "show_heat_map", %{heat_points: heat_points}) {:raw_packets, _packets} -> socket end end defp zoom_to_latest_packet(socket, nil), do: socket defp zoom_to_latest_packet(socket, latest_packet) do if latest_packet.lat && latest_packet.lon do lat = Aprsme.EncodingUtils.to_float(latest_packet.lat) || 0.0 lon = Aprsme.EncodingUtils.to_float(latest_packet.lon) || 0.0 packet_data = DataBuilder.build_packet_data(latest_packet) socket |> push_event("zoom_to_location", %{lat: lat, lng: lon, zoom: 12}) |> push_event("add_historical_packets_batch", %{ packets: [packet_data], batch: 0, is_final: false }) else socket end end end