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

View file

@ -163,6 +163,58 @@ defmodule Aprsme.CachedQueries do
stats
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
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;\">..."
"""
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)
# Check if this is an overlay symbol

View file

@ -70,104 +70,135 @@ defmodule AprsmeWeb.MapLive.Index do
@impl true
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
# Subscribe to packet updates
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets")
# Schedule periodic cleanup of old packets
Process.send_after(self(), :cleanup_old_packets, 60_000)
end
# Get deployment timestamp from config (set during application startup)
deployed_at = Aprsme.Release.deployed_at()
# 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)
socket
end
defp setup_additional_subscriptions(socket) do
if connected?(socket) do
Endpoint.subscribe("aprs_messages")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
# Check for callsign parameter
tracked_callsign = Map.get(params, "call", "")
socket
end
# If tracking a callsign and no explicit map parameters, center on that callsign
{final_map_center, final_map_zoom} =
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}
defp has_explicit_url_params?(params) do
!!(params["lat"] || params["lng"] || params["z"])
end
_ ->
{map_center, map_zoom}
defp determine_map_location(params, session) do
{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
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
else
{map_center, map_zoom}
end
end
# Calculate initial bounds based on final center and zoom level
initial_bounds = calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom)
{:ok,
assign(socket,
map_ready: false,
map_bounds: initial_bounds,
map_center: final_map_center,
map_zoom: final_map_zoom,
should_skip_initial_url_update: should_skip_initial_url_update,
visible_packets: %{},
historical_packets: %{},
overlay_callsign: "",
tracked_callsign: tracked_callsign,
trail_duration: "1",
historical_hours: "1",
packet_age_threshold: one_hour_ago,
slideover_open: true,
deployed_at: deployed_at,
map_page: true,
packet_buffer: [],
buffer_timer: nil,
all_packets: %{},
station_popup_open: false,
initial_bounds_loaded: false,
needs_initial_historical_load: tracked_callsign != ""
)}
defp 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
}) do
assign(socket,
map_ready: false,
map_bounds: initial_bounds,
map_center: final_map_center,
map_zoom: final_map_zoom,
should_skip_initial_url_update: should_skip_initial_url_update,
visible_packets: %{},
historical_packets: %{},
overlay_callsign: "",
tracked_callsign: tracked_callsign,
trail_duration: "1",
historical_hours: "1",
packet_age_threshold: one_hour_ago,
slideover_open: true,
deployed_at: deployed_at,
map_page: true,
packet_buffer: [],
buffer_timer: nil,
all_packets: %{},
station_popup_open: false,
initial_bounds_loaded: false,
needs_initial_historical_load: tracked_callsign != ""
)
end
# Calculate approximate bounds based on center point and zoom level
@ -232,13 +263,9 @@ defmodule AprsmeWeb.MapLive.Index do
)
end
# Handle both bounds_changed and update_bounds events
@impl true
def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do
handle_bounds_update(bounds, socket)
end
@impl true
def handle_event("update_bounds", %{"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
@ -266,10 +293,7 @@ defmodule AprsmeWeb.MapLive.Index do
true -> lng
end
socket =
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})
socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12)
{:noreply, socket}
end
@ -278,19 +302,13 @@ defmodule AprsmeWeb.MapLive.Index do
def handle_event("clear_and_reload_markers", _params, socket) do
# Only filter the current visible_packets, do not re-query the database
filtered_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_callsign, packet} ->
within_bounds?(packet, socket.assigns.map_bounds) &&
packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold)
end)
|> Map.new()
filter_packets_by_time_and_bounds(
socket.assigns.visible_packets,
socket.assigns.map_bounds,
socket.assigns.packet_age_threshold
)
locale = Map.get(socket.assigns, :locale, "en")
visible_packets_list =
filtered_packets
|> Enum.map(fn {_callsign, packet} -> PacketUtils.build_packet_data(packet, false, locale) end)
|> Enum.filter(& &1)
visible_packets_list = build_packet_data_list_from_map(filtered_packets, false, socket)
socket = assign(socket, visible_packets: filtered_packets)
@ -301,13 +319,7 @@ defmodule AprsmeWeb.MapLive.Index do
send_heat_map_data(socket, filtered_packets)
else
# Use regular markers for high zoom levels
if Enum.any?(visible_packets_list) do
socket
|> push_event("show_markers", %{})
|> push_event("add_markers", %{markers: visible_packets_list})
else
socket
end
add_markers_if_any(socket, visible_packets_list)
end
{:noreply, socket}
@ -327,11 +339,7 @@ defmodule AprsmeWeb.MapLive.Index do
socket.assigns.map_center.lng == @default_center.lng do
socket
else
push_event(socket, "zoom_to_location", %{
lat: socket.assigns.map_center.lat,
lng: socket.assigns.map_center.lng,
zoom: socket.assigns.map_zoom
})
zoom_to_current_location(socket)
end
# 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}")
# 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 =
case center do
%{"lat" => lat_val} -> lat_val
@ -511,76 +536,85 @@ defmodule AprsmeWeb.MapLive.Index do
# Validate and clamp values
lat = max(-90.0, min(90.0, lat))
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
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)
# If crossing threshold, trigger appropriate display mode
socket =
if crossing_threshold do
if zoom <= 8 do
# Switching to heat map
socket = push_event(socket, "clear_all_markers", %{})
send_heat_map_for_current_bounds(socket)
else
# Switching to markers
trigger_marker_display(socket)
if crossing_threshold do
handle_zoom_threshold_crossing(socket, zoom)
else
socket
end
end
defp is_crossing_zoom_threshold?(old_zoom, new_zoom) do
(old_zoom <= 8 and new_zoom > 8) or (old_zoom > 8 and new_zoom <= 8)
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
else
socket
end
# Update URL without page reload, but skip on initial load if requested
socket =
if socket.assigns[:should_skip_initial_url_update] && !socket.assigns[:initial_bounds_loaded] do
# Skip URL update on initial load
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
_ ->
socket
end
end
# If bounds are included, also process bounds update
socket =
case Map.get(params, "bounds") do
%{"north" => north, "south" => south, "east" => east, "west" => west} ->
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}
defp should_process_bounds?(socket, new_bounds) do
socket.assigns.map_bounds != new_bounds or
!socket.assigns[:initial_bounds_loaded] or
socket.assigns[:needs_initial_historical_load]
end
@impl true
@ -801,8 +835,7 @@ defmodule AprsmeWeb.MapLive.Index do
send_heat_map_for_current_bounds(socket)
else
# We're in marker mode - send individual marker
locale = Map.get(socket.assigns, :locale, "en")
marker_data = PacketUtils.build_packet_data(packet, true, locale)
marker_data = PacketUtils.build_packet_data(packet, true, get_locale(socket))
if marker_data do
# 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)
# Only update the client if there are expired markers
socket =
if expired_keys == [] do
socket
else
Enum.reduce(expired_keys, socket, fn key, acc ->
push_event(acc, "remove_marker", %{id: key})
end)
end
socket = remove_markers_batch(socket, expired_keys)
# Use Map.drop/2 for better performance
updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys)
@ -1420,15 +1446,14 @@ defmodule AprsmeWeb.MapLive.Index do
end
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 =
Enum.group_by(historical_packets, fn packet ->
packet.sender || "unknown"
end)
# Batch fetch weather information for all callsigns to avoid N+1 queries
callsigns = Map.keys(grouped_packets)
weather_callsigns = get_weather_callsigns_batch(callsigns)
# Build weather callsign set from packets themselves (no DB query needed)
weather_callsigns = build_weather_callsign_set(historical_packets)
# For each callsign group, find the most recent packet and mark it appropriately
grouped_packets
@ -1575,27 +1600,11 @@ defmodule AprsmeWeb.MapLive.Index do
end
end
# Batch fetch weather callsigns to avoid N+1 queries
defp get_weather_callsigns_batch(callsigns) when is_list(callsigns) do
import Ecto.Query
# Normalize callsigns
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()
# Build weather callsign set from packets themselves (avoids DB query)
defp build_weather_callsign_set(packets) do
packets
|> Enum.filter(&PacketUtils.weather_packet?/1)
|> MapSet.new(fn packet -> String.upcase(packet.sender || "") end)
end
# Calculate distance between two lat/lon points in meters using Haversine formula
@ -1665,29 +1674,36 @@ defmodule AprsmeWeb.MapLive.Index do
end
end
# Calculate optimal batch size based on zoom level
# Higher zoom = smaller viewport = load everything at once for speed
@spec calculate_batch_size_for_zoom(integer()) :: integer()
# High zoom - load everything at once (up to 500 packets)
defp calculate_batch_size_for_zoom(zoom) when zoom >= 10, do: 500
# Medium zoom
defp calculate_batch_size_for_zoom(zoom) when zoom >= 8, do: 100
# Zoomed out
defp calculate_batch_size_for_zoom(zoom) when zoom >= 5, do: 75
# Very zoomed out
defp calculate_batch_size_for_zoom(_), do: 50
# Consolidated zoom-based loading parameters
@spec get_loading_params_for_zoom(integer()) :: {batch_size :: integer(), batch_count :: integer()}
defp get_loading_params_for_zoom(zoom) do
cond do
# Very zoomed in - load everything at once, minimal batches
zoom >= 15 -> {500, 2}
# Moderately zoomed in
zoom >= 12 -> {500, 3}
# High zoom - still load a lot
zoom >= 10 -> {500, 4}
# 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()
# Very zoomed in - fewer batches
defp calculate_batch_count_for_zoom(zoom) when zoom >= 15, do: 2
# Moderately zoomed in
defp calculate_batch_count_for_zoom(zoom) when zoom >= 12, do: 3
# 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
defp calculate_batch_count_for_zoom(zoom) do
{_, batch_count} = get_loading_params_for_zoom(zoom)
batch_count
end
@spec load_historical_batch(Socket.t(), integer()) :: Socket.t()
defp load_historical_batch(socket, batch_offset) do
@ -1833,6 +1849,55 @@ defmodule AprsmeWeb.MapLive.Index do
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
def terminate(_reason, socket) do
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
# Convert map of packets to list
packet_list = Map.values(filtered_packets)
# 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
send_heat_map_for_packets(socket, packet_list)
end
defp send_heat_map_for_current_bounds(socket) do
@ -1941,41 +1997,78 @@ defmodule AprsmeWeb.MapLive.Index do
# Filter by bounds
filtered_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 ->
Map.get(packet, :id) || Map.get(packet, "id")
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
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} ->
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
# 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
# Clear heat map and show markers
socket = push_event(socket, "show_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.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
socket = add_markers_if_any(socket, visible_packets_list)
# Trigger historical packet reload for markers
start_progressive_historical_loading(socket)
@ -1999,25 +2092,11 @@ defmodule AprsmeWeb.MapLive.Index do
)
# Remove out-of-bounds packets and markers immediately
new_visible_packets =
socket.assigns.visible_packets
|> 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)
new_visible_packets = filter_packets_by_bounds(socket.assigns.visible_packets, map_bounds)
packets_to_remove = reject_packets_by_bounds(socket.assigns.visible_packets, map_bounds)
# Remove markers for out-of-bounds packets
socket =
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
socket = remove_markers_batch(socket, packets_to_remove)
# Only clear historical packets if:
# 1. Bounds actually changed AND
@ -2079,49 +2158,10 @@ defmodule AprsmeWeb.MapLive.Index do
defp parse_rf_path(_), do: []
# Get positions of path stations from database
defp get_path_station_positions(callsigns, socket) when is_list(callsigns) do
# Query for the most recent position of each station
# Show complete RF path regardless of map bounds - only the originating station needs to be visible
_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
# Get positions of path stations from database with caching
defp get_path_station_positions(callsigns, _socket) when is_list(callsigns) do
# Use cached queries for better performance
CachedQueries.get_path_station_positions_cached(callsigns)
end
defp get_path_station_positions(_, _), do: []

View file

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