defmodule AprsmeWeb.PacketsLive.Index do @moduledoc false use AprsmeWeb, :live_view use Gettext, backend: AprsmeWeb.Gettext alias Aprsme.EncodingUtils @flush_interval_ms 150 @max_stream_size 100 @impl true def mount(_params, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") end {:ok, socket |> put_private(:packet_buffer, []) |> put_private(:flush_timer, nil) |> stream(:packets, [], at: 0)} end @impl true def handle_info({:postgres_packet, payload}, socket) do # Buffer in socket.private to avoid triggering a rerender on every packet. # A periodic flush timer drains the buffer and batch-inserts into the stream, # which keeps client-side morphdom from being overwhelmed during high-traffic bursts. socket = update_in(socket.private.packet_buffer, &[payload | &1]) socket = ensure_flush_timer(socket) {:noreply, socket} end @impl true def handle_info(:flush_packets, socket) do packets = Enum.reverse(socket.private.packet_buffer) socket = put_private(socket, :packet_buffer, []) socket = put_private(socket, :flush_timer, nil) # Batch insert all accumulated packets into the stream. # Processing newest-first with at: 0 preserves descending-chronological order. socket = Enum.reduce(packets, socket, fn payload, s -> sanitized = EncodingUtils.sanitize_packet(payload) stream_insert(s, :packets, sanitized, at: 0) end) # Trim stream to keep at most @max_stream_size items. socket = trim_stream(socket) {:noreply, socket} end defp ensure_flush_timer(socket) do if socket.private.flush_timer do socket else timer = Process.send_after(self(), :flush_packets, @flush_interval_ms) put_private(socket, :flush_timer, timer) end end defp trim_stream(socket) do stream = socket.assigns.streams.packets count = Enum.count(stream) if count > @max_stream_size do excess = count - @max_stream_size # Remove excess from the end (oldest items) to_remove = stream |> Enum.reverse() |> Enum.take(excess) Enum.reduce(to_remove, socket, fn {id, _}, s -> stream_delete(s, :packets, id) end) else socket end end @doc """ Extract a coordinate (:lat or :lon) from a packet, checking multiple sources: 1. Direct :lat/:lon keys 2. Packet struct with PostGIS location 3. data_extended map with :latitude/:longitude keys """ def extract_coordinate(packet, which) when which in [:lat, :lon] do direct_key = which extended_key = if which == :lat, do: :latitude, else: :longitude Map.get(packet, direct_key) || extract_from_location(packet, which) || extract_from_data_extended(packet, extended_key) end @doc """ Format a coordinate value for display with up to 6 decimal places. """ def format_coordinate(nil), do: "" def format_coordinate(value) when is_float(value) do "~.6f" |> :io_lib.format([value]) |> List.to_string() end def format_coordinate(value) when is_binary(value) do Regex.replace(~r/(\d+\.\d{1,6})\d*/, value, "\\1") end def format_coordinate(value), do: to_string(value) defp extract_from_location(%Aprsme.Packet{location: %Geo.Point{coordinates: {_lon, lat}}}, :lat), do: lat defp extract_from_location(%Aprsme.Packet{location: %Geo.Point{coordinates: {lon, _lat}}}, :lon), do: lon defp extract_from_location(_, _), do: nil defp extract_from_data_extended(packet, key) do case Map.get(packet, :data_extended) do %{} = data -> Map.get(data, key) || Map.get(data, to_string(key)) _ -> nil end end end