aprs.me/lib/aprsme_web/live/map_live/packet_processor.ex
Graham McIntire 84e65232e5
Batch RF path queries, batch marker removals, and JS callsign index
- Replace N+1 get_latest_packet_for_callsign calls in RF path loading
  with single get_latest_packets_for_callsigns batch query (DISTINCT ON)
- Change batch packet processing to collect marker removal IDs and push
  one remove_markers_batch event instead of individual remove_marker calls
- Add callsignIndex reverse map (callsign_group → Set<markerID>) for O(1)
  lookup in new_packet/new_packets handlers instead of scanning all markers
2026-02-20 09:04:22 -06:00

225 lines
8.7 KiB
Elixir

defmodule AprsmeWeb.MapLive.PacketProcessor do
@moduledoc """
Handles real-time packet processing, filtering, and display logic.
"""
import Phoenix.Component, only: [assign: 3]
alias Aprsme.GeoUtils
alias AprsmeWeb.Live.Shared.BoundsUtils
alias AprsmeWeb.Live.Shared.CoordinateUtils
alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils
alias AprsmeWeb.MapLive.DataBuilder
alias AprsmeWeb.MapLive.DisplayManager
alias Phoenix.LiveView
alias Phoenix.LiveView.Socket
@max_visible_packets 1000
@doc """
Process a packet for display on the map.
Pushes events directly to the socket (used for single-packet path).
"""
@spec process_packet_for_display(map(), Socket.t()) :: {:noreply, Socket.t()}
def process_packet_for_display(packet, socket) do
{lat, lon, _data_extended} = CoordinateUtils.get_coordinates(packet)
callsign_key = SharedPacketUtils.get_callsign_key(packet)
# Handle packet visibility logic
socket = handle_packet_visibility(packet, lat, lon, callsign_key, socket)
# Update last update timestamp for real-time display in map sidebar
socket = assign(socket, :last_update_at, DateTime.utc_now())
{:noreply, socket}
end
@doc """
Process a packet for batch display. Updates socket state but does NOT push
events. Returns `{socket, marker_data | nil, removed_id | nil}` where
marker_data is the data that should be included in a batched push_event,
and removed_id is a callsign key that was removed from visible_packets
(for batched remove_markers_batch events).
"""
@spec process_packet_for_marker_data(map(), Socket.t()) :: {Socket.t(), map() | nil, String.t() | nil}
def process_packet_for_marker_data(packet, socket) do
{lat, lon, _data_extended} = CoordinateUtils.get_coordinates(packet)
callsign_key = SharedPacketUtils.get_callsign_key(packet)
batch_visibility_action(packet, lat, lon, callsign_key, socket)
end
@doc """
Handle packet visibility logic based on bounds and existing markers.
"""
@spec handle_packet_visibility(map(), number() | nil, number() | nil, binary(), Socket.t()) :: Socket.t()
def handle_packet_visibility(packet, lat, lon, callsign_key, socket) do
visibility_action(packet, lat, lon, callsign_key, socket)
end
defp visibility_action(packet, lat, lon, callsign_key, socket) when not is_nil(lat) and not is_nil(lon) do
in_bounds = within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds)
has_marker = Map.has_key?(socket.assigns.visible_packets, callsign_key)
dispatch_visibility({in_bounds, has_marker}, packet, lat, lon, callsign_key, socket)
end
defp visibility_action(_packet, _lat, _lon, _callsign_key, socket), do: socket
defp dispatch_visibility({false, true}, _packet, _lat, _lon, callsign_key, socket) do
remove_marker_from_map(callsign_key, socket)
end
defp dispatch_visibility({true, false}, packet, lat, lon, _callsign_key, socket) do
handle_valid_postgres_packet(packet, lat, lon, socket)
end
defp dispatch_visibility({true, true}, packet, lat, lon, callsign_key, socket) do
existing_packet = socket.assigns.visible_packets[callsign_key]
{existing_lat, existing_lon, _} = CoordinateUtils.get_coordinates(existing_packet)
if is_number(existing_lat) and is_number(existing_lon) and
GeoUtils.significant_movement?(existing_lat, existing_lon, lat, lon, 50) do
handle_valid_postgres_packet(packet, lat, lon, socket)
else
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
assign(socket, :visible_packets, new_visible_packets)
end
end
defp dispatch_visibility(_, _packet, _lat, _lon, _callsign_key, socket), do: socket
@doc """
Handle valid postgres packet by adding it to visible packets and displaying it.
"""
@spec handle_valid_postgres_packet(map(), number() | nil, number() | nil, Socket.t()) :: Socket.t()
def handle_valid_postgres_packet(packet, _lat, _lon, socket) do
{socket, marker_data} = prepare_packet_state(packet, socket)
cond do
socket.assigns.map_zoom <= 8 ->
send_heat_map_for_current_bounds(socket)
marker_data ->
send_marker_with_popup_check(socket, marker_data)
true ->
socket
end
end
@doc """
Remove marker from map and update visible packets.
"""
@spec remove_marker_from_map(binary(), Socket.t()) :: Socket.t()
def remove_marker_from_map(callsign_key, socket) do
socket = LiveView.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
# Batch-mode visibility: same logic as visibility_action but returns
# {socket, marker_data | nil, removed_id | nil} without pushing events
defp batch_visibility_action(packet, lat, lon, callsign_key, socket) when not is_nil(lat) and not is_nil(lon) do
in_bounds = within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds)
has_marker = Map.has_key?(socket.assigns.visible_packets, callsign_key)
batch_dispatch({in_bounds, has_marker}, packet, lat, lon, callsign_key, socket)
end
defp batch_visibility_action(_packet, _lat, _lon, _callsign_key, socket) do
{socket, nil, nil}
end
# Out of bounds but has marker — remove state only, return the removed key
# so the caller can batch all removals into one push_event
defp batch_dispatch({false, true}, _packet, _lat, _lon, callsign_key, socket) do
new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key)
socket = assign(socket, :visible_packets, new_visible_packets)
{socket, nil, callsign_key}
end
# In bounds, no existing marker — add it
defp batch_dispatch({true, false}, packet, _lat, _lon, _callsign_key, socket) do
{socket, marker_data} = prepare_packet_state(packet, socket)
{socket, marker_data, nil}
end
# In bounds, existing marker — update if significant movement
defp batch_dispatch({true, true}, packet, lat, lon, callsign_key, socket) do
existing_packet = socket.assigns.visible_packets[callsign_key]
{existing_lat, existing_lon, _} = CoordinateUtils.get_coordinates(existing_packet)
if is_number(existing_lat) and is_number(existing_lon) and
GeoUtils.significant_movement?(existing_lat, existing_lon, lat, lon, 50) do
{socket, marker_data} = prepare_packet_state(packet, socket)
{socket, marker_data, nil}
else
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
{assign(socket, :visible_packets, new_visible_packets), nil, nil}
end
end
defp batch_dispatch(_, _packet, _lat, _lon, _callsign_key, socket) do
{socket, nil, nil}
end
# Shared: updates visible_packets state and builds marker data.
# Returns {socket, marker_data | nil}. Does NOT push events.
# When an existing visible packet for the same callsign is replaced,
# the marker_data includes a "convert_from" key with the old callsign_key
# so the JS can convert the old marker to a historical dot via O(1) lookup.
defp prepare_packet_state(packet, socket) do
callsign_key = SharedPacketUtils.get_callsign_key(packet)
# Check if we're replacing an existing visible packet for this callsign
had_existing = Map.has_key?(socket.assigns.visible_packets, callsign_key)
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
new_visible_packets =
if map_size(new_visible_packets) > @max_visible_packets do
SharedPacketUtils.prune_oldest_packets(new_visible_packets, @max_visible_packets)
else
new_visible_packets
end
socket = assign(socket, :visible_packets, new_visible_packets)
marker_data =
if socket.assigns.map_zoom > 8 do
data = DataBuilder.build_packet_data(packet, true, get_locale(socket))
if data && had_existing do
Map.put(data, "convert_from", callsign_key)
else
data
end
end
{socket, marker_data}
end
# Private helper functions
defp within_bounds?(coords, bounds) do
BoundsUtils.within_bounds?(coords, bounds)
end
defp get_locale(socket) do
Map.get(socket.assigns, :locale, "en")
end
defp send_heat_map_for_current_bounds(socket) do
DisplayManager.send_heat_map_for_current_bounds(socket)
end
defp send_marker_with_popup_check(socket, marker_data) do
# Strip convert_from — the single-packet JS handler uses its own scan logic
marker_data = Map.delete(marker_data, "convert_from")
if socket.assigns.station_popup_open do
LiveView.push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false))
else
LiveView.push_event(socket, "new_packet", marker_data)
end
end
end