aprs.me/lib/aprsme_web/live/map_live/display_manager.ex
Graham McIntire c8c42b7cc8
Some checks are pending
Elixir CI / Build and test (push) Waiting to run
Elixir CI / Dialyzer (push) Waiting to run
Elixir CI / Build and Push Docker Image (push) Blocked by required conditions
refactor: map page efficiency, trail accuracy, and spidering fixes
- Delete dead map_fixes.ts (305 lines unused)
- Consolidate CSS from index.ex into components.ex map_styles
- Guard redundant update_map_state → handle_params event loop
- Guard packet_age_threshold recalculation on trail_duration change
- Align server/client movement thresholds to 20m
- O(1) duplicate detection in TrailManager.addPosition
- Cache trail centers and optimize color assignment
- Fix dynamic trail cleanup cutoff (was hardcoded 24h)
- Preserve OMS spiderfied state on zoom threshold crossing
- Rebuild OMS after clearAllMarkers
- Cap client markers at 10,000 with historical eviction
- Fix no-op start_progressive_historical_loading
- Reduce historical loading timeout 30s → 15s
- Default map_bounds to worldwide instead of US-only
- Simplify bounds-update guard logic
2026-08-02 14:04:26 -05:00

222 lines
6.8 KiB
Elixir

defmodule AprsmeWeb.MapLive.DisplayManager do
@moduledoc """
Handles heat maps, markers, and zoom threshold display logic.
"""
alias Aprsme.Packets.Clustering
alias AprsmeWeb.Live.Shared.BoundsUtils
alias AprsmeWeb.MapLive.DataBuilder
alias AprsmeWeb.MapLive.HistoricalLoader
alias Phoenix.LiveView
alias Phoenix.LiveView.Socket
# Zoom level at or below which the map switches to heat map mode
@heat_map_max_zoom 8
@doc """
Handle zoom threshold crossing between heat map and marker modes.
When tracking a callsign at low zoom, show trail line instead of heat map.
"""
@spec handle_zoom_threshold_crossing(Socket.t(), integer()) :: Socket.t()
def handle_zoom_threshold_crossing(socket, zoom) do
tracked_callsign = socket.assigns.tracked_callsign || ""
if zoom <= @heat_map_max_zoom do
# Switching to low zoom mode
socket = LiveView.push_event(socket, "clear_all_markers", %{})
# If tracking a callsign, show trail line; otherwise show heat map
if tracked_callsign == "" do
send_heat_map_for_current_bounds(socket)
else
send_trail_line_for_tracked_callsign(socket)
end
else
# Switching to markers
trigger_marker_display(socket)
end
end
@doc """
Check if zoom level crosses the threshold between heat map and marker modes.
"""
@spec crossing_zoom_threshold?(integer(), integer()) :: boolean()
def crossing_zoom_threshold?(old_zoom, new_zoom) do
(old_zoom <= @heat_map_max_zoom and new_zoom > @heat_map_max_zoom) or
(old_zoom > @heat_map_max_zoom and new_zoom <= @heat_map_max_zoom)
end
@doc """
Send heat map data for current bounds.
"""
@spec send_heat_map_for_current_bounds(Socket.t()) :: Socket.t()
def send_heat_map_for_current_bounds(socket) do
# Get all packets within current bounds
all_packets =
Map.values(socket.assigns.visible_packets) ++
Map.values(socket.assigns.historical_packets)
# Filter by bounds
filtered_packets =
all_packets
|> BoundsUtils.filter_packets_by_bounds(socket.assigns.map_bounds)
|> Enum.uniq_by(fn packet ->
Map.get(packet, :id)
end)
send_heat_map_for_packets(socket, filtered_packets)
end
@doc """
Send heat map data for specific packets.
"""
@spec send_heat_map_for_packets(Socket.t(), list()) :: Socket.t()
def send_heat_map_for_packets(socket, packets) do
# Get clustering data
case Clustering.cluster_packets(packets, socket.assigns.map_zoom) do
{:heat_map, heat_points} ->
LiveView.push_event(socket, "show_heat_map", %{heat_points: heat_points})
{:raw_packets, _packets} ->
# Shouldn't happen at zoom <= 8, but handle it anyway
socket
end
end
@doc """
Send trail line data for tracked callsign.
Connects all position points chronologically, regardless of distance.
"""
@spec send_trail_line_for_tracked_callsign(Socket.t()) :: Socket.t()
def send_trail_line_for_tracked_callsign(socket) do
callsign = socket.assigns.tracked_callsign
threshold = socket.assigns.packet_age_threshold
packets =
trail_packets_for_callsign(
socket.assigns.visible_packets,
socket.assigns.historical_packets,
callsign,
threshold
)
if packets == [] do
socket
else
push_trail_line(socket, callsign, packets)
end
end
@doc """
Send heat map data for filtered packets.
"""
@spec send_heat_map_data(Socket.t(), map()) :: Socket.t()
def send_heat_map_data(socket, filtered_packets) do
# Convert map of packets to list
packet_list = Map.values(filtered_packets)
send_heat_map_for_packets(socket, packet_list)
end
@doc """
Trigger marker display mode.
"""
@spec trigger_marker_display(Socket.t()) :: Socket.t()
def trigger_marker_display(socket) do
# Clear heat map and show markers
socket = LiveView.push_event(socket, "show_markers", %{})
# Re-send all visible packets as markers
visible_packets_list = DataBuilder.build_packet_data_list_from_map(socket.assigns.visible_packets, true, socket)
socket = add_markers_if_any(socket, visible_packets_list)
# Trigger historical packet reload for markers
start_progressive_historical_loading(socket)
end
@doc """
Add markers to map if any exist.
"""
@spec add_markers_if_any(Socket.t(), list()) :: Socket.t()
def add_markers_if_any(socket, []), do: socket
def add_markers_if_any(socket, markers) do
LiveView.push_event(socket, "add_markers", %{markers: markers})
end
@doc """
Remove multiple markers from map.
"""
@spec remove_markers_batch(Socket.t(), list()) :: Socket.t()
def remove_markers_batch(socket, []), do: socket
def remove_markers_batch(socket, marker_ids) do
LiveView.push_event(socket, "remove_markers_batch", %{ids: marker_ids})
end
defp start_progressive_historical_loading(socket) do
HistoricalLoader.start_progressive_historical_loading(socket)
end
# credo:disable-for-next-line
# TODO: Add hop-distance filtering (100km threshold) to match client-side
# TrailManager segmentation. Currently shows all time-filtered positions
# as a single polyline, which can create misleading long lines for
# stations that jump between widely separated locations.
defp trail_packets_for_callsign(visible_packets, historical_packets, callsign, threshold) do
(Map.values(visible_packets) ++ Map.values(historical_packets))
|> Enum.reduce(%{}, fn p, acc ->
Map.put_new(acc, trail_packet_key(p), p)
end)
|> Map.values()
|> Enum.filter(&tracked_packet?(&1, callsign, threshold))
|> Enum.sort_by(&packet_timestamp/1, :asc)
end
defp tracked_packet?(packet, callsign, threshold) do
sender = Map.get(packet, :sender)
received_at = packet_received_at(packet)
String.upcase(sender) == String.upcase(callsign) &&
DateTime.compare(received_at, threshold) != :lt
end
defp push_trail_line(socket, callsign, packets) do
LiveView.push_event(socket, "show_trail_line", %{
callsign: callsign,
points: Enum.map(packets, &trail_point/1)
})
end
defp trail_point(packet) do
lat = Map.get(packet, :lat)
lon = Map.get(packet, :lon)
received_at = packet_received_at(packet)
%{
lat: Aprsme.EncodingUtils.to_float(lat) || 0.0,
lng: Aprsme.EncodingUtils.to_float(lon) || 0.0,
timestamp: DateTime.to_iso8601(received_at)
}
end
defp packet_received_at(packet) do
Map.get(packet, :received_at)
end
defp packet_timestamp(packet) do
packet
|> packet_received_at()
|> DateTime.to_unix(:microsecond)
end
defp trail_packet_key(packet) do
Map.get(packet, :id) ||
{
Map.get(packet, :sender),
Map.get(packet, :lat),
Map.get(packet, :lon),
packet_received_at(packet)
}
end
end