This commit is contained in:
Graham McIntire 2025-07-13 16:49:20 -05:00
commit 610a5b8949
No known key found for this signature in database
6 changed files with 513 additions and 339 deletions

View file

@ -163,6 +163,58 @@ defmodule Aprsme.CachedQueries do
stats stats
end end
@doc """
Get path station positions with caching
"""
def get_path_station_positions_cached(callsigns) when is_list(callsigns) do
cache_key = generate_cache_key("path_stations", Enum.sort(callsigns))
case Cachex.get(:query_cache, cache_key) do
{:ok, result} when not is_nil(result) ->
result
_ ->
query = """
WITH latest_positions AS (
SELECT DISTINCT ON (sender)
sender,
lat,
lon,
received_at
FROM packets
WHERE
sender = ANY($1::text[])
AND lat IS NOT NULL
AND lon IS NOT NULL
AND received_at > NOW() - INTERVAL '7 days'
ORDER BY sender, received_at DESC
)
SELECT sender, lat, lon
FROM latest_positions
ORDER BY array_position($1::text[], sender)
"""
result =
case Ecto.Adapters.SQL.query(Repo, query, [callsigns]) do
{:ok, %{rows: rows}} ->
Enum.map(rows, fn [callsign, lat, lon] ->
%{
callsign: callsign,
lat: Decimal.to_float(lat),
lng: Decimal.to_float(lon)
}
end)
{:error, _} ->
[]
end
# Cache for 5 minutes
Cachex.put(:query_cache, cache_key, result, ttl: @cache_ttl_medium)
result
end
end
# Private helper functions # Private helper functions
defp generate_cache_key(prefix, data) do defp generate_cache_key(prefix, data) do

View file

@ -234,6 +234,27 @@ defmodule AprsmeWeb.AprsSymbol do
"<div style=\"position: relative; width: 32px; height: 32px; display: flex; align-items: center;\">..." "<div style=\"position: relative; width: 32px; height: 32px; display: flex; align-items: center;\">..."
""" """
def render_marker_html(symbol_table, symbol_code, callsign \\ nil, size \\ 32) do def render_marker_html(symbol_table, symbol_code, callsign \\ nil, size \\ 32) do
# For symbols without callsigns, use Cachex for better caching
if is_nil(callsign) do
cache_key = "symbol_html:#{symbol_table}:#{symbol_code}:#{size}"
case Cachex.get(:symbol_cache, cache_key) do
{:ok, html} when not is_nil(html) ->
html
_ ->
html = generate_marker_html(symbol_table, symbol_code, nil, size)
# Cache for 1 hour since symbols don't change
Cachex.put(:symbol_cache, cache_key, html, ttl: to_timeout(hour: 1))
html
end
else
# For symbols with callsigns, generate directly (callsigns are dynamic)
generate_marker_html(symbol_table, symbol_code, callsign, size)
end
end
defp generate_marker_html(symbol_table, symbol_code, callsign, size) do
sprite_info = get_sprite_info(symbol_table, symbol_code) sprite_info = get_sprite_info(symbol_table, symbol_code)
# Check if this is an overlay symbol # Check if this is an overlay symbol

View file

@ -70,104 +70,135 @@ defmodule AprsmeWeb.MapLive.Index do
@impl true @impl true
def mount(params, session, socket) do def mount(params, session, socket) do
require Logger socket = setup_subscriptions(socket)
# Basic setup
deployed_at = Aprsme.Release.deployed_at()
one_hour_ago = TimeUtils.one_day_ago()
# Parse and determine map location
{map_center, map_zoom, should_skip_initial_url_update} = determine_map_location(params, session)
# Setup defaults
socket = assign_defaults(socket, one_hour_ago)
socket = assign(socket, initial_historical_completed: false)
# Setup additional subscriptions if connected
socket = setup_additional_subscriptions(socket)
# Handle callsign tracking
tracked_callsign = Map.get(params, "call", "")
{final_map_center, final_map_zoom} =
handle_callsign_tracking(
tracked_callsign,
map_center,
map_zoom,
has_explicit_url_params?(params)
)
# Calculate initial bounds
initial_bounds = calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom)
# Final socket assignment
{:ok,
finalize_mount_assigns(socket, %{
initial_bounds: initial_bounds,
final_map_center: final_map_center,
final_map_zoom: final_map_zoom,
should_skip_initial_url_update: should_skip_initial_url_update,
tracked_callsign: tracked_callsign,
deployed_at: deployed_at,
one_hour_ago: one_hour_ago
})}
end
defp setup_subscriptions(socket) do
if connected?(socket) do if connected?(socket) do
# Subscribe to packet updates
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets") Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets")
# Schedule periodic cleanup of old packets
Process.send_after(self(), :cleanup_old_packets, 60_000) Process.send_after(self(), :cleanup_old_packets, 60_000)
end end
# Get deployment timestamp from config (set during application startup) socket
deployed_at = Aprsme.Release.deployed_at() end
# Show 24 hours for more symbol variety
one_hour_ago = TimeUtils.one_day_ago()
# Parse map state from URL parameters
{url_center, url_zoom} = parse_map_params(params)
# Check for IP geolocation in session
# Check if URL params were explicitly provided (not just defaults)
has_explicit_url_params = !!(params["lat"] || params["lng"] || params["z"])
{map_center, map_zoom, should_skip_initial_url_update} =
case session["ip_geolocation"] do
%{"lat" => lat, "lng" => lng} when is_number(lat) and is_number(lng) ->
if has_explicit_url_params do
# URL params explicitly provided - use them
{url_center, url_zoom, false}
else
# No explicit URL params - use IP geolocation
geo_center = %{lat: lat, lng: lng}
{geo_center, 11, true}
end
_ ->
# No geolocation available, use URL params or defaults
# Skip initial URL update if no explicit params were provided
{url_center, url_zoom, !has_explicit_url_params}
end
socket = assign_defaults(socket, one_hour_ago)
# Initialize the flag to track if initial historical load is completed
socket = assign(socket, initial_historical_completed: false)
defp setup_additional_subscriptions(socket) do
if connected?(socket) do if connected?(socket) do
Endpoint.subscribe("aprs_messages") Endpoint.subscribe("aprs_messages")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end end
# Check for callsign parameter socket
tracked_callsign = Map.get(params, "call", "") end
# If tracking a callsign and no explicit map parameters, center on that callsign defp has_explicit_url_params?(params) do
{final_map_center, final_map_zoom} = !!(params["lat"] || params["lng"] || params["z"])
if tracked_callsign != "" and not has_explicit_url_params do end
case CachedQueries.get_latest_packet_for_callsign_cached(tracked_callsign) do
%{lat: lat, lon: lon} when is_number(lat) and is_number(lon) ->
{%{lat: lat, lng: lon}, 12}
_ -> defp determine_map_location(params, session) do
{map_center, map_zoom} {url_center, url_zoom} = parse_map_params(params)
has_explicit_url_params = has_explicit_url_params?(params)
case session["ip_geolocation"] do
%{"lat" => lat, "lng" => lng} when is_number(lat) and is_number(lng) ->
if has_explicit_url_params do
{url_center, url_zoom, false}
else
{%{lat: lat, lng: lng}, 11, true}
end end
else
{map_center, map_zoom} _ ->
{url_center, url_zoom, !has_explicit_url_params}
end
end
defp handle_callsign_tracking(tracked_callsign, map_center, map_zoom, has_explicit_url_params) do
if tracked_callsign != "" and not has_explicit_url_params do
case CachedQueries.get_latest_packet_for_callsign_cached(tracked_callsign) do
%{lat: lat, lon: lon} when is_number(lat) and is_number(lon) ->
{%{lat: lat, lng: lon}, 12}
_ ->
{map_center, map_zoom}
end end
else
{map_center, map_zoom}
end
end
# Calculate initial bounds based on final center and zoom level defp finalize_mount_assigns(socket, %{
initial_bounds = calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom) initial_bounds: initial_bounds,
final_map_center: final_map_center,
{:ok, final_map_zoom: final_map_zoom,
assign(socket, should_skip_initial_url_update: should_skip_initial_url_update,
map_ready: false, tracked_callsign: tracked_callsign,
map_bounds: initial_bounds, deployed_at: deployed_at,
map_center: final_map_center, one_hour_ago: one_hour_ago
map_zoom: final_map_zoom, }) do
should_skip_initial_url_update: should_skip_initial_url_update, assign(socket,
visible_packets: %{}, map_ready: false,
historical_packets: %{}, map_bounds: initial_bounds,
overlay_callsign: "", map_center: final_map_center,
tracked_callsign: tracked_callsign, map_zoom: final_map_zoom,
trail_duration: "1", should_skip_initial_url_update: should_skip_initial_url_update,
historical_hours: "1", visible_packets: %{},
packet_age_threshold: one_hour_ago, historical_packets: %{},
slideover_open: true, overlay_callsign: "",
deployed_at: deployed_at, tracked_callsign: tracked_callsign,
map_page: true, trail_duration: "1",
packet_buffer: [], historical_hours: "1",
buffer_timer: nil, packet_age_threshold: one_hour_ago,
all_packets: %{}, slideover_open: true,
station_popup_open: false, deployed_at: deployed_at,
initial_bounds_loaded: false, map_page: true,
needs_initial_historical_load: tracked_callsign != "" packet_buffer: [],
)} buffer_timer: nil,
all_packets: %{},
station_popup_open: false,
initial_bounds_loaded: false,
needs_initial_historical_load: tracked_callsign != ""
)
end end
# Calculate approximate bounds based on center point and zoom level # Calculate approximate bounds based on center point and zoom level
@ -232,13 +263,9 @@ defmodule AprsmeWeb.MapLive.Index do
) )
end end
# Handle both bounds_changed and update_bounds events
@impl true @impl true
def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do def handle_event(event, %{"bounds" => bounds}, socket) when event in ["bounds_changed", "update_bounds"] do
handle_bounds_update(bounds, socket)
end
@impl true
def handle_event("update_bounds", %{"bounds" => bounds}, socket) do
handle_bounds_update(bounds, socket) handle_bounds_update(bounds, socket)
end end
@ -266,10 +293,7 @@ defmodule AprsmeWeb.MapLive.Index do
true -> lng true -> lng
end end
socket = socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12)
socket
|> assign(map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
|> push_event("zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
{:noreply, socket} {:noreply, socket}
end end
@ -278,19 +302,13 @@ defmodule AprsmeWeb.MapLive.Index do
def handle_event("clear_and_reload_markers", _params, socket) do def handle_event("clear_and_reload_markers", _params, socket) do
# Only filter the current visible_packets, do not re-query the database # Only filter the current visible_packets, do not re-query the database
filtered_packets = filtered_packets =
socket.assigns.visible_packets filter_packets_by_time_and_bounds(
|> Enum.filter(fn {_callsign, packet} -> socket.assigns.visible_packets,
within_bounds?(packet, socket.assigns.map_bounds) && socket.assigns.map_bounds,
packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold) socket.assigns.packet_age_threshold
end) )
|> Map.new()
locale = Map.get(socket.assigns, :locale, "en") visible_packets_list = build_packet_data_list_from_map(filtered_packets, false, socket)
visible_packets_list =
filtered_packets
|> Enum.map(fn {_callsign, packet} -> PacketUtils.build_packet_data(packet, false, locale) end)
|> Enum.filter(& &1)
socket = assign(socket, visible_packets: filtered_packets) socket = assign(socket, visible_packets: filtered_packets)
@ -301,13 +319,7 @@ defmodule AprsmeWeb.MapLive.Index do
send_heat_map_data(socket, filtered_packets) send_heat_map_data(socket, filtered_packets)
else else
# Use regular markers for high zoom levels # Use regular markers for high zoom levels
if Enum.any?(visible_packets_list) do add_markers_if_any(socket, visible_packets_list)
socket
|> push_event("show_markers", %{})
|> push_event("add_markers", %{markers: visible_packets_list})
else
socket
end
end end
{:noreply, socket} {:noreply, socket}
@ -327,11 +339,7 @@ defmodule AprsmeWeb.MapLive.Index do
socket.assigns.map_center.lng == @default_center.lng do socket.assigns.map_center.lng == @default_center.lng do
socket socket
else else
push_event(socket, "zoom_to_location", %{ zoom_to_current_location(socket)
lat: socket.assigns.map_center.lat,
lng: socket.assigns.map_center.lng,
zoom: socket.assigns.map_zoom
})
end end
# Wait for JavaScript to send the actual map bounds before loading historical packets # Wait for JavaScript to send the actual map bounds before loading historical packets
@ -495,7 +503,24 @@ defmodule AprsmeWeb.MapLive.Index do
Logger.debug("update_map_state event received: center=#{inspect(center)}, zoom=#{zoom}") Logger.debug("update_map_state event received: center=#{inspect(center)}, zoom=#{zoom}")
# Parse center coordinates # Parse and validate coordinates
{lat, lng} = parse_center_coordinates(center, socket)
zoom = clamp_zoom(zoom)
map_center = %{lat: lat, lng: lng}
# Update map state
socket = update_map_state(socket, map_center, zoom)
# Handle URL updates
socket = handle_url_update(socket, lat, lng, zoom)
# Process bounds if included
socket = process_bounds_from_params(socket, params)
{:noreply, socket}
end
defp parse_center_coordinates(center, socket) do
lat = lat =
case center do case center do
%{"lat" => lat_val} -> lat_val %{"lat" => lat_val} -> lat_val
@ -511,76 +536,85 @@ defmodule AprsmeWeb.MapLive.Index do
# Validate and clamp values # Validate and clamp values
lat = max(-90.0, min(90.0, lat)) lat = max(-90.0, min(90.0, lat))
lng = max(-180.0, min(180.0, lng)) lng = max(-180.0, min(180.0, lng))
zoom = max(1, min(20, zoom))
map_center = %{lat: lat, lng: lng} {lat, lng}
end
# Check if we're crossing the heat map/marker threshold defp clamp_zoom(zoom) do
max(1, min(20, zoom))
end
defp update_map_state(socket, map_center, zoom) do
old_zoom = socket.assigns.map_zoom old_zoom = socket.assigns.map_zoom
crossing_threshold = (old_zoom <= 8 and zoom > 8) or (old_zoom > 8 and zoom <= 8) crossing_threshold = is_crossing_zoom_threshold?(old_zoom, zoom)
# Update socket state
socket = assign(socket, map_center: map_center, map_zoom: zoom) socket = assign(socket, map_center: map_center, map_zoom: zoom)
# If crossing threshold, trigger appropriate display mode if crossing_threshold do
socket = handle_zoom_threshold_crossing(socket, zoom)
if crossing_threshold do else
if zoom <= 8 do socket
# Switching to heat map end
socket = push_event(socket, "clear_all_markers", %{}) end
send_heat_map_for_current_bounds(socket)
else defp is_crossing_zoom_threshold?(old_zoom, new_zoom) do
# Switching to markers (old_zoom <= 8 and new_zoom > 8) or (old_zoom > 8 and new_zoom <= 8)
trigger_marker_display(socket) end
defp handle_zoom_threshold_crossing(socket, zoom) do
if zoom <= 8 do
# Switching to heat map
socket
|> push_event("clear_all_markers", %{})
|> send_heat_map_for_current_bounds()
else
# Switching to markers
trigger_marker_display(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
require Logger
Logger.debug("Skipping URL update on initial load")
assign(socket, should_skip_initial_url_update: false)
else
require Logger
new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}"
Logger.debug("Updating URL to: #{new_path}")
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
require Logger
Logger.debug(
"Sending bounds update (initial_load: #{!socket.assigns[:initial_bounds_loaded]}, " <>
"needs_historical: #{socket.assigns[:needs_initial_historical_load]}): #{inspect(map_bounds)}"
)
send(self(), {:process_bounds_update, map_bounds})
end end
else
socket socket
end
# Update URL without page reload, but skip on initial load if requested _ ->
socket = socket
if socket.assigns[:should_skip_initial_url_update] && !socket.assigns[:initial_bounds_loaded] do end
# Skip URL update on initial load end
Logger.debug("Skipping URL update on initial load")
# Clear the flag after first update
assign(socket, should_skip_initial_url_update: false)
else
new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}"
Logger.debug("Updating URL to: #{new_path}")
push_patch(socket, to: new_path, replace: true)
end
# If bounds are included, also process bounds update defp should_process_bounds?(socket, new_bounds) do
socket = socket.assigns.map_bounds != new_bounds or
case Map.get(params, "bounds") do !socket.assigns[:initial_bounds_loaded] or
%{"north" => north, "south" => south, "east" => east, "west" => west} -> socket.assigns[:needs_initial_historical_load]
map_bounds = %{
north: north,
south: south,
east: east,
west: west
}
# Trigger bounds processing if bounds changed OR if this is the initial load OR if we need initial historical load
if socket.assigns.map_bounds != map_bounds or
!socket.assigns[:initial_bounds_loaded] or
socket.assigns[:needs_initial_historical_load] do
require Logger
Logger.debug(
"Sending bounds update (initial_load: #{!socket.assigns[:initial_bounds_loaded]}, needs_historical: #{socket.assigns[:needs_initial_historical_load]}): #{inspect(map_bounds)}"
)
send(self(), {:process_bounds_update, map_bounds})
end
socket
_ ->
socket
end
{:noreply, socket}
end end
@impl true @impl true
@ -801,8 +835,7 @@ defmodule AprsmeWeb.MapLive.Index do
send_heat_map_for_current_bounds(socket) send_heat_map_for_current_bounds(socket)
else else
# We're in marker mode - send individual marker # We're in marker mode - send individual marker
locale = Map.get(socket.assigns, :locale, "en") marker_data = PacketUtils.build_packet_data(packet, true, get_locale(socket))
marker_data = PacketUtils.build_packet_data(packet, true, locale)
if marker_data do if marker_data do
# Only show new packet popup if no station popup is currently open # Only show new packet popup if no station popup is currently open
@ -1321,14 +1354,7 @@ defmodule AprsmeWeb.MapLive.Index do
|> Enum.map(fn {key, _} -> key end) |> Enum.map(fn {key, _} -> key end)
# Only update the client if there are expired markers # Only update the client if there are expired markers
socket = socket = remove_markers_batch(socket, expired_keys)
if expired_keys == [] do
socket
else
Enum.reduce(expired_keys, socket, fn key, acc ->
push_event(acc, "remove_marker", %{id: key})
end)
end
# Use Map.drop/2 for better performance # Use Map.drop/2 for better performance
updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys) updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys)
@ -1420,15 +1446,14 @@ defmodule AprsmeWeb.MapLive.Index do
end end
defp build_packet_data_list(historical_packets) do defp build_packet_data_list(historical_packets) do
# Group by callsign and identify most recent packet for each # Include weather data in initial grouping to avoid separate query
grouped_packets = grouped_packets =
Enum.group_by(historical_packets, fn packet -> Enum.group_by(historical_packets, fn packet ->
packet.sender || "unknown" packet.sender || "unknown"
end) end)
# Batch fetch weather information for all callsigns to avoid N+1 queries # Build weather callsign set from packets themselves (no DB query needed)
callsigns = Map.keys(grouped_packets) weather_callsigns = build_weather_callsign_set(historical_packets)
weather_callsigns = get_weather_callsigns_batch(callsigns)
# For each callsign group, find the most recent packet and mark it appropriately # For each callsign group, find the most recent packet and mark it appropriately
grouped_packets grouped_packets
@ -1575,27 +1600,11 @@ defmodule AprsmeWeb.MapLive.Index do
end end
end end
# Batch fetch weather callsigns to avoid N+1 queries # Build weather callsign set from packets themselves (avoids DB query)
defp get_weather_callsigns_batch(callsigns) when is_list(callsigns) do defp build_weather_callsign_set(packets) do
import Ecto.Query packets
|> Enum.filter(&PacketUtils.weather_packet?/1)
# Normalize callsigns |> MapSet.new(fn packet -> String.upcase(packet.sender || "") end)
normalized_callsigns = Enum.map(callsigns, &String.upcase/1)
# Single query to find all callsigns that have weather packets
query =
from p in Aprsme.Packet,
where: fragment("UPPER(?)", p.sender) in ^normalized_callsigns,
where:
not is_nil(p.temperature) or not is_nil(p.humidity) or not is_nil(p.pressure) or
not is_nil(p.wind_speed) or not is_nil(p.wind_direction) or not is_nil(p.rain_1h),
select: fragment("UPPER(?)", p.sender),
distinct: true
weather_callsigns = Aprsme.Repo.all(query)
MapSet.new(weather_callsigns)
rescue
_ -> MapSet.new()
end end
# Calculate distance between two lat/lon points in meters using Haversine formula # Calculate distance between two lat/lon points in meters using Haversine formula
@ -1665,29 +1674,36 @@ defmodule AprsmeWeb.MapLive.Index do
end end
end end
# Calculate optimal batch size based on zoom level # Consolidated zoom-based loading parameters
# Higher zoom = smaller viewport = load everything at once for speed @spec get_loading_params_for_zoom(integer()) :: {batch_size :: integer(), batch_count :: integer()}
@spec calculate_batch_size_for_zoom(integer()) :: integer() defp get_loading_params_for_zoom(zoom) do
# High zoom - load everything at once (up to 500 packets) cond do
defp calculate_batch_size_for_zoom(zoom) when zoom >= 10, do: 500 # Very zoomed in - load everything at once, minimal batches
# Medium zoom zoom >= 15 -> {500, 2}
defp calculate_batch_size_for_zoom(zoom) when zoom >= 8, do: 100 # Moderately zoomed in
# Zoomed out zoom >= 12 -> {500, 3}
defp calculate_batch_size_for_zoom(zoom) when zoom >= 5, do: 75 # High zoom - still load a lot
# Very zoomed out zoom >= 10 -> {500, 4}
defp calculate_batch_size_for_zoom(_), do: 50 # Medium zoom
zoom >= 8 -> {100, 4}
# Zoomed out
zoom >= 5 -> {75, 5}
# Very zoomed out - smaller batches, more of them
true -> {50, 5}
end
end
@spec calculate_batch_size_for_zoom(integer()) :: integer()
defp calculate_batch_size_for_zoom(zoom) do
{batch_size, _} = get_loading_params_for_zoom(zoom)
batch_size
end
# Calculate optimal number of batches based on zoom level
# Higher zoom = fewer batches needed since viewport is smaller
@spec calculate_batch_count_for_zoom(integer()) :: integer() @spec calculate_batch_count_for_zoom(integer()) :: integer()
# Very zoomed in - fewer batches defp calculate_batch_count_for_zoom(zoom) do
defp calculate_batch_count_for_zoom(zoom) when zoom >= 15, do: 2 {_, batch_count} = get_loading_params_for_zoom(zoom)
# Moderately zoomed in batch_count
defp calculate_batch_count_for_zoom(zoom) when zoom >= 12, do: 3 end
# Medium zoom
defp calculate_batch_count_for_zoom(zoom) when zoom >= 8, do: 4
# Zoomed out - more batches
defp calculate_batch_count_for_zoom(_), do: 5
@spec load_historical_batch(Socket.t(), integer()) :: Socket.t() @spec load_historical_batch(Socket.t(), integer()) :: Socket.t()
defp load_historical_batch(socket, batch_offset) do defp load_historical_batch(socket, batch_offset) do
@ -1833,6 +1849,55 @@ defmodule AprsmeWeb.MapLive.Index do
end end
end end
# Helper functions to reduce duplicate filtering logic
@spec filter_packets_by_bounds(map(), map()) :: map()
defp filter_packets_by_bounds(packets_map, bounds) when is_map(packets_map) do
packets_map
|> Enum.filter(fn {_k, packet} -> within_bounds?(packet, bounds) end)
|> Map.new()
end
@spec filter_packets_by_bounds(list(), map()) :: list()
defp filter_packets_by_bounds(packets_list, bounds) when is_list(packets_list) do
Enum.filter(packets_list, &within_bounds?(&1, bounds))
end
@spec reject_packets_by_bounds(map(), map()) :: list()
defp reject_packets_by_bounds(packets_map, bounds) when is_map(packets_map) do
packets_map
|> Enum.reject(fn {_k, packet} -> within_bounds?(packet, bounds) end)
|> Enum.map(fn {k, _} -> k end)
end
@spec filter_packets_by_time_and_bounds(map(), map(), DateTime.t()) :: map()
defp filter_packets_by_time_and_bounds(packets, bounds, time_threshold) do
packets
|> Enum.filter(fn {_callsign, packet} ->
within_bounds?(packet, bounds) &&
packet_within_time_threshold?(packet, time_threshold)
end)
|> Map.new()
end
# Helper functions for marker operations
@spec remove_markers_batch(Socket.t(), list()) :: Socket.t()
defp remove_markers_batch(socket, []), do: socket
defp remove_markers_batch(socket, marker_ids) do
Enum.reduce(marker_ids, socket, fn id, acc ->
push_event(acc, "remove_marker", %{id: id})
end)
end
@spec add_markers_if_any(Socket.t(), list()) :: Socket.t()
defp add_markers_if_any(socket, []), do: socket
defp add_markers_if_any(socket, markers) do
push_event(socket, "add_markers", %{markers: markers})
end
@impl true @impl true
def terminate(_reason, socket) do def terminate(_reason, socket) do
if socket.assigns.buffer_timer, do: Process.cancel_timer(socket.assigns.buffer_timer) if socket.assigns.buffer_timer, do: Process.cancel_timer(socket.assigns.buffer_timer)
@ -1920,16 +1985,7 @@ defmodule AprsmeWeb.MapLive.Index do
defp send_heat_map_data(socket, filtered_packets) do defp send_heat_map_data(socket, filtered_packets) do
# Convert map of packets to list # Convert map of packets to list
packet_list = Map.values(filtered_packets) packet_list = Map.values(filtered_packets)
send_heat_map_for_packets(socket, packet_list)
# Get clustering data
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} ->
# Shouldn't happen at zoom <= 8, but handle it anyway
socket
end
end end
defp send_heat_map_for_current_bounds(socket) do defp send_heat_map_for_current_bounds(socket) do
@ -1941,41 +1997,78 @@ defmodule AprsmeWeb.MapLive.Index do
# Filter by bounds # Filter by bounds
filtered_packets = filtered_packets =
all_packets all_packets
|> Enum.filter(&within_bounds?(&1, socket.assigns.map_bounds)) |> filter_packets_by_bounds(socket.assigns.map_bounds)
|> Enum.uniq_by(fn packet -> |> Enum.uniq_by(fn packet ->
Map.get(packet, :id) || Map.get(packet, "id") Map.get(packet, :id) || Map.get(packet, "id")
end) end)
send_heat_map_for_packets(socket, filtered_packets)
end
# Common heat map display logic
defp send_heat_map_for_packets(socket, packets) do
# Get clustering data # Get clustering data
case Clustering.cluster_packets(filtered_packets, socket.assigns.map_zoom) do case Clustering.cluster_packets(packets, socket.assigns.map_zoom) do
{:heat_map, heat_points} -> {:heat_map, heat_points} ->
push_event(socket, "show_heat_map", %{heat_points: heat_points}) push_event(socket, "show_heat_map", %{heat_points: heat_points})
{:raw_packets, _packets} -> {:raw_packets, _packets} ->
# Shouldn't happen at zoom <= 8, but handle it anyway
socket socket
end end
end end
# Helper to build packet data list from a map of packets
defp build_packet_data_list_from_map(packets_map, is_most_recent, socket) do
locale = get_locale(socket)
packets_map
|> Enum.map(fn {_callsign, packet} ->
PacketUtils.build_packet_data(packet, is_most_recent, locale)
end)
|> Enum.filter(& &1)
end
# Helper to get locale from socket
defp get_locale(socket) do
Map.get(socket.assigns, :locale, "en")
end
# Helper to update map center and zoom to location
defp update_and_zoom_to_location(socket, lat, lng, zoom) do
socket
|> assign(map_center: %{lat: lat, lng: lng}, map_zoom: zoom)
|> push_event("zoom_to_location", %{lat: lat, lng: lng, zoom: zoom})
end
# Helper to zoom to current location
defp zoom_to_current_location(socket) do
push_event(socket, "zoom_to_location", %{
lat: socket.assigns.map_center.lat,
lng: socket.assigns.map_center.lng,
zoom: socket.assigns.map_zoom
})
end
# Helper function to parse coordinates from various formats
@spec parse_coordinate(any()) :: float()
defp parse_coordinate(coord) do
cond do
is_binary(coord) -> String.to_float(coord)
is_integer(coord) -> coord / 1.0
is_float(coord) -> coord
true -> 0.0
end
end
defp trigger_marker_display(socket) do defp trigger_marker_display(socket) do
# Clear heat map and show markers # Clear heat map and show markers
socket = push_event(socket, "show_markers", %{}) socket = push_event(socket, "show_markers", %{})
# Re-send all visible packets as markers # Re-send all visible packets as markers
locale = Map.get(socket.assigns, :locale, "en") visible_packets_list = build_packet_data_list_from_map(socket.assigns.visible_packets, true, socket)
visible_packets_list = socket = add_markers_if_any(socket, visible_packets_list)
socket.assigns.visible_packets
|> Enum.map(fn {_callsign, packet} ->
PacketUtils.build_packet_data(packet, true, locale)
end)
|> Enum.filter(& &1)
socket =
if Enum.any?(visible_packets_list) do
push_event(socket, "add_markers", %{markers: visible_packets_list})
else
socket
end
# Trigger historical packet reload for markers # Trigger historical packet reload for markers
start_progressive_historical_loading(socket) start_progressive_historical_loading(socket)
@ -1999,25 +2092,11 @@ defmodule AprsmeWeb.MapLive.Index do
) )
# Remove out-of-bounds packets and markers immediately # Remove out-of-bounds packets and markers immediately
new_visible_packets = new_visible_packets = filter_packets_by_bounds(socket.assigns.visible_packets, map_bounds)
socket.assigns.visible_packets packets_to_remove = reject_packets_by_bounds(socket.assigns.visible_packets, map_bounds)
|> Enum.filter(fn {_k, packet} -> within_bounds?(packet, map_bounds) end)
|> Map.new()
packets_to_remove =
socket.assigns.visible_packets
|> Enum.reject(fn {_k, packet} -> within_bounds?(packet, map_bounds) end)
|> Enum.map(fn {k, _} -> k end)
# Remove markers for out-of-bounds packets # Remove markers for out-of-bounds packets
socket = socket = remove_markers_batch(socket, packets_to_remove)
if packets_to_remove == [] do
socket
else
Enum.reduce(packets_to_remove, socket, fn k, acc ->
push_event(acc, "remove_marker", %{id: k})
end)
end
# Only clear historical packets if: # Only clear historical packets if:
# 1. Bounds actually changed AND # 1. Bounds actually changed AND
@ -2079,49 +2158,10 @@ defmodule AprsmeWeb.MapLive.Index do
defp parse_rf_path(_), do: [] defp parse_rf_path(_), do: []
# Get positions of path stations from database # Get positions of path stations from database with caching
defp get_path_station_positions(callsigns, socket) when is_list(callsigns) do defp get_path_station_positions(callsigns, _socket) when is_list(callsigns) do
# Query for the most recent position of each station # Use cached queries for better performance
# Show complete RF path regardless of map bounds - only the originating station needs to be visible CachedQueries.get_path_station_positions_cached(callsigns)
_bounds = socket.assigns.map_bounds
query = """
WITH latest_positions AS (
SELECT DISTINCT ON (sender)
sender,
lat,
lon,
received_at
FROM packets
WHERE
sender = ANY($1::text[])
AND lat IS NOT NULL
AND lon IS NOT NULL
AND received_at > NOW() - INTERVAL '7 days'
ORDER BY sender, received_at DESC
)
SELECT sender, lat, lon
FROM latest_positions
ORDER BY array_position($1::text[], sender)
"""
case Ecto.Adapters.SQL.query(
Aprsme.Repo,
query,
[callsigns]
) do
{:ok, %{rows: rows}} ->
Enum.map(rows, fn [callsign, lat, lon] ->
%{
callsign: callsign,
lat: Decimal.to_float(lat),
lng: Decimal.to_float(lon)
}
end)
{:error, _} ->
[]
end
end end
defp get_path_station_positions(_, _), do: [] defp get_path_station_positions(_, _), do: []

View file

@ -1,10 +1,9 @@
defmodule AprsmeWeb.MapLive.RfPathTest do defmodule AprsmeWeb.MapLive.RfPathTest do
use AprsmeWeb.ConnCase use AprsmeWeb.ConnCase
import AprsmeWeb.TestHelpers
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Aprsme.Packet
alias Aprsme.Repo
alias AprsmeWeb.MapLive.Index alias AprsmeWeb.MapLive.Index
describe "RF path parsing" do describe "RF path parsing" do
@ -13,15 +12,12 @@ defmodule AprsmeWeb.MapLive.RfPathTest do
# Since it's a private function, we test the behavior # Since it's a private function, we test the behavior
{:ok, _digi1} = {:ok, _digi1} =
Repo.insert(%Packet{ create_test_packet(%{
sender: "K5GVL-10", sender: "K5GVL-10",
base_callsign: "K5GVL", base_callsign: "K5GVL",
ssid: "10", ssid: "10",
lat: Decimal.new("33.1000"), lat: Decimal.new("33.1000"),
lon: Decimal.new("-96.6000"), lon: Decimal.new("-96.6000"),
has_position: true,
received_at: DateTime.truncate(DateTime.utc_now(), :second),
data_type: "position",
symbol_table_id: "#", symbol_table_id: "#",
symbol_code: "r" symbol_code: "r"
}) })
@ -44,27 +40,21 @@ defmodule AprsmeWeb.MapLive.RfPathTest do
test "parses complex RF paths with multiple stations", %{conn: conn} do test "parses complex RF paths with multiple stations", %{conn: conn} do
# Create multiple stations that could be in the path # Create multiple stations that could be in the path
{:ok, _station1} = {:ok, _station1} =
Repo.insert(%Packet{ create_test_packet(%{
sender: "N5ABC", sender: "N5ABC",
base_callsign: "N5ABC", base_callsign: "N5ABC",
ssid: nil, ssid: nil,
lat: Decimal.new("33.2000"), lat: Decimal.new("33.2000"),
lon: Decimal.new("-96.6000"), lon: Decimal.new("-96.6000")
has_position: true,
received_at: DateTime.truncate(DateTime.utc_now(), :second),
data_type: "position"
}) })
{:ok, _station2} = {:ok, _station2} =
Repo.insert(%Packet{ create_test_packet(%{
sender: "WB5DEF-1", sender: "WB5DEF-1",
base_callsign: "WB5DEF", base_callsign: "WB5DEF",
ssid: "1", ssid: "1",
lat: Decimal.new("33.3000"), lat: Decimal.new("33.3000"),
lon: Decimal.new("-96.7000"), lon: Decimal.new("-96.7000")
has_position: true,
received_at: DateTime.truncate(DateTime.utc_now(), :second),
data_type: "position"
}) })
{:ok, view, _html} = live(conn, "/") {:ok, view, _html} = live(conn, "/")
@ -230,12 +220,7 @@ defmodule AprsmeWeb.MapLive.RfPathTest do
{:ok, view, _html} = live(conn, "/") {:ok, view, _html} = live(conn, "/")
# Set map bounds to a small area around Texas that excludes the outside station # Set map bounds to a small area around Texas that excludes the outside station
bounds = %{ bounds = texas_bounds()
"north" => "33.0",
"south" => "32.0",
"east" => "-96.0",
"west" => "-97.0"
}
render_hook(view, "bounds_changed", %{"bounds" => bounds}) render_hook(view, "bounds_changed", %{"bounds" => bounds})
@ -257,12 +242,7 @@ defmodule AprsmeWeb.MapLive.RfPathTest do
{:ok, view, _html} = live(conn, "/") {:ok, view, _html} = live(conn, "/")
# Set very restrictive bounds that exclude both stations # Set very restrictive bounds that exclude both stations
bounds = %{ bounds = restrictive_bounds()
"north" => "31.0",
"south" => "30.0",
"east" => "-95.0",
"west" => "-96.0"
}
render_hook(view, "bounds_changed", %{"bounds" => bounds}) render_hook(view, "bounds_changed", %{"bounds" => bounds})

View file

@ -0,0 +1,81 @@
defmodule AprsmeWeb.TestHelpers do
@moduledoc """
Common test helper functions to reduce duplication across test files.
"""
alias Aprsme.Packet
alias Aprsme.Repo
@doc """
Creates a test packet with default values that can be overridden.
"""
def create_test_packet(attrs \\ %{}) do
default_attrs = %{
sender: "TEST-1",
base_callsign: "TEST",
ssid: "1",
lat: Decimal.new("33.0000"),
lon: Decimal.new("-96.0000"),
has_position: true,
received_at: DateTime.truncate(DateTime.utc_now(), :second),
data_type: "position"
}
attrs = Map.merge(default_attrs, attrs)
Repo.insert(%Packet{
sender: attrs.sender,
base_callsign: attrs.base_callsign,
ssid: attrs.ssid,
lat: attrs.lat,
lon: attrs.lon,
has_position: attrs.has_position,
received_at: attrs.received_at,
data_type: attrs.data_type,
symbol_table_id: Map.get(attrs, :symbol_table_id),
symbol_code: Map.get(attrs, :symbol_code),
temperature: Map.get(attrs, :temperature),
humidity: Map.get(attrs, :humidity),
wind_speed: Map.get(attrs, :wind_speed)
})
end
@doc """
Creates common test bounds for Texas area.
"""
def texas_bounds do
%{
"north" => "33.0",
"south" => "32.0",
"east" => "-96.0",
"west" => "-97.0"
}
end
@doc """
Creates common test bounds for a restrictive area.
"""
def restrictive_bounds do
%{
"north" => "31.0",
"south" => "30.0",
"east" => "-95.0",
"west" => "-96.0"
}
end
@doc """
Common time calculations used across tests.
"""
def hours_ago(hours) when is_number(hours) do
DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
end
def minutes_ago(minutes) when is_number(minutes) do
DateTime.add(DateTime.utc_now(), -minutes * 60, :second)
end
def days_ago(days) when is_number(days) do
DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
end
end

2
vendor/aprs vendored

@ -1 +1 @@
Subproject commit b964e1da6e159900d01416c178a2cd20a721447e Subproject commit 80589d4ad745c795830c0bb96e485d69ce69011a