Remove unused 30s timer, skip heatmap recluster on every single packet, trail dedup O(n^2)->O(n) Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
238 lines
9.6 KiB
Elixir
238 lines
9.6 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 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 = socket.assigns.visible_packets[callsign_key]
|
|
|
|
if moved_significantly?(existing, lat, lon) 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
|
|
|
|
# A station's marker is moved only if we can compare the two coordinates AND
|
|
# the delta exceeds the 50-meter significance threshold. Any missing/invalid
|
|
# existing coordinate means "not a significant move" (keep existing marker).
|
|
defp moved_significantly?(existing_packet, new_lat, new_lon) do
|
|
case CoordinateUtils.get_coordinates(existing_packet) do
|
|
{elat, elon, _} when is_number(elat) and is_number(elon) ->
|
|
GeoUtils.significant_movement?(elat, elon, new_lat, new_lon, 50)
|
|
|
|
_ ->
|
|
false
|
|
end
|
|
end
|
|
|
|
@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)
|
|
render_for_zoom(socket.assigns.map_zoom, marker_data, socket)
|
|
end
|
|
|
|
defp render_for_zoom(zoom, _marker_data, socket) when zoom <= 8, do: socket
|
|
|
|
defp render_for_zoom(_zoom, nil, socket), do: socket
|
|
|
|
defp render_for_zoom(_zoom, marker_data, socket) do
|
|
send_marker_with_popup_check(socket, marker_data)
|
|
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 = socket.assigns.visible_packets[callsign_key]
|
|
|
|
if moved_significantly?(existing, lat, lon) 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)
|
|
had_existing = Map.has_key?(socket.assigns.visible_packets, callsign_key)
|
|
|
|
new_visible_packets =
|
|
socket.assigns.visible_packets
|
|
|> Map.put(callsign_key, packet)
|
|
|> prune_if_over_cap()
|
|
|
|
socket = assign(socket, :visible_packets, new_visible_packets)
|
|
marker_data = build_marker_data(socket.assigns.map_zoom, packet, callsign_key, had_existing, socket)
|
|
|
|
{socket, marker_data}
|
|
end
|
|
|
|
# Cap the visible-packets map at @max_visible_packets; pattern-match on
|
|
# map_size so we only call the pruner when we're actually over the cap.
|
|
defp prune_if_over_cap(packets) when map_size(packets) > @max_visible_packets do
|
|
SharedPacketUtils.prune_oldest_packets(packets, @max_visible_packets)
|
|
end
|
|
|
|
defp prune_if_over_cap(packets), do: packets
|
|
|
|
# At low zoom, we use the heatmap instead of individual markers, so no
|
|
# marker_data is built at all.
|
|
defp build_marker_data(zoom, _packet, _key, _had_existing, _socket) when zoom <= 8, do: nil
|
|
|
|
defp build_marker_data(_zoom, packet, callsign_key, had_existing, socket) do
|
|
case DataBuilder.build_packet_data(packet, true, get_locale(socket)) do
|
|
nil -> nil
|
|
data -> maybe_tag_convert_from(data, had_existing, callsign_key)
|
|
end
|
|
end
|
|
|
|
defp maybe_tag_convert_from(data, true, callsign_key), do: Map.put(data, "convert_from", callsign_key)
|
|
defp maybe_tag_convert_from(data, false, _callsign_key), do: data
|
|
|
|
# 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_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")
|
|
push_new_packet(socket.assigns.station_popup_open, socket, marker_data)
|
|
end
|
|
|
|
# When a popup is already open, keep it open by pinning openPopup=false on
|
|
# the fresh marker so the client doesn't re-open or steal focus.
|
|
defp push_new_packet(true, socket, marker_data) do
|
|
LiveView.push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false))
|
|
end
|
|
|
|
defp push_new_packet(false, socket, marker_data) do
|
|
LiveView.push_event(socket, "new_packet", marker_data)
|
|
end
|
|
end
|