refactor
This commit is contained in:
parent
fa89712587
commit
610a5b8949
6 changed files with 520 additions and 346 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
81
test/support/test_helpers.ex
Normal file
81
test/support/test_helpers.ex
Normal 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
2
vendor/aprs
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit b964e1da6e159900d01416c178a2cd20a721447e
|
||||
Subproject commit 80589d4ad745c795830c0bb96e485d69ce69011a
|
||||
Loading…
Add table
Reference in a new issue