main map improvements
This commit is contained in:
parent
532a81c0b4
commit
fa89712587
8 changed files with 590 additions and 21 deletions
112
assets/js/map.ts
112
assets/js/map.ts
|
|
@ -271,6 +271,9 @@ let MapAPRSMap = {
|
||||||
|
|
||||||
// Track marker states to prevent unnecessary operations
|
// Track marker states to prevent unnecessary operations
|
||||||
self.markerStates = new Map<string, MarkerState>();
|
self.markerStates = new Map<string, MarkerState>();
|
||||||
|
|
||||||
|
// Store RF path lines for hover visualization
|
||||||
|
self.rfPathLines = [];
|
||||||
|
|
||||||
// Force initial size calculation
|
// Force initial size calculation
|
||||||
try {
|
try {
|
||||||
|
|
@ -897,6 +900,76 @@ let MapAPRSMap = {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle drawing RF path lines when hovering over a station
|
||||||
|
self.handleEvent("draw_rf_path", (data: {
|
||||||
|
station_lat: number,
|
||||||
|
station_lng: number,
|
||||||
|
path_stations: Array<{callsign: string, lat: number, lng: number}>
|
||||||
|
}) => {
|
||||||
|
if (!self.map || !data.path_stations || data.path_stations.length === 0) return;
|
||||||
|
|
||||||
|
// Clear any existing path lines
|
||||||
|
if (self.rfPathLines) {
|
||||||
|
self.rfPathLines.forEach(line => self.map!.removeLayer(line));
|
||||||
|
}
|
||||||
|
self.rfPathLines = [];
|
||||||
|
|
||||||
|
// Draw lines from station to each digipeater/igate in sequence
|
||||||
|
let prevLat = data.station_lat;
|
||||||
|
let prevLng = data.station_lng;
|
||||||
|
|
||||||
|
data.path_stations.forEach((station, index) => {
|
||||||
|
// Draw line from previous position to this station
|
||||||
|
const line = L.polyline(
|
||||||
|
[[prevLat, prevLng], [station.lat, station.lng]],
|
||||||
|
{
|
||||||
|
color: '#FF6B6B',
|
||||||
|
weight: 3,
|
||||||
|
opacity: 0.8,
|
||||||
|
dashArray: index === 0 ? null : '5, 10', // Solid line for first hop, dashed for subsequent
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
line.addTo(self.map!);
|
||||||
|
self.rfPathLines.push(line);
|
||||||
|
|
||||||
|
// Add a small circle marker at the digipeater/igate location
|
||||||
|
const circle = L.circleMarker([station.lat, station.lng], {
|
||||||
|
radius: 6,
|
||||||
|
fillColor: '#2563eb',
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.8
|
||||||
|
});
|
||||||
|
|
||||||
|
circle.bindTooltip(station.callsign, {
|
||||||
|
permanent: false,
|
||||||
|
direction: 'top',
|
||||||
|
offset: [0, -10]
|
||||||
|
});
|
||||||
|
|
||||||
|
circle.addTo(self.map!);
|
||||||
|
self.rfPathLines.push(circle);
|
||||||
|
|
||||||
|
// Update prev position for next line
|
||||||
|
prevLat = station.lat;
|
||||||
|
prevLng = station.lng;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle clearing RF path lines
|
||||||
|
self.handleEvent("clear_rf_path", () => {
|
||||||
|
if (self.rfPathLines) {
|
||||||
|
self.rfPathLines.forEach(line => {
|
||||||
|
if (self.map && self.map.hasLayer(line)) {
|
||||||
|
self.map.removeLayer(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.rfPathLines = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle bounds-based marker filtering
|
// Handle bounds-based marker filtering
|
||||||
self.handleEvent("filter_markers_by_bounds", (data: { bounds: BoundsData }) => {
|
self.handleEvent("filter_markers_by_bounds", (data: { bounds: BoundsData }) => {
|
||||||
if (data.bounds) {
|
if (data.bounds) {
|
||||||
|
|
@ -1156,6 +1229,45 @@ let MapAPRSMap = {
|
||||||
lng: lng,
|
lng: lng,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle marker hover for RF path visualization
|
||||||
|
// Check for non-empty RF path (not TCPIP and not empty string)
|
||||||
|
if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) {
|
||||||
|
console.log("Adding hover handlers for RF packet:", data.callsign, "path:", data.path);
|
||||||
|
marker.on("mouseover", () => {
|
||||||
|
console.log("Marker hover start for:", data.callsign);
|
||||||
|
// Check if LiveView is still connected before sending event
|
||||||
|
if (self.pushEvent && !self.isDestroyed) {
|
||||||
|
try {
|
||||||
|
self.pushEvent("marker_hover_start", {
|
||||||
|
id: data.id,
|
||||||
|
callsign: data.callsign,
|
||||||
|
path: data.path,
|
||||||
|
lat: lat,
|
||||||
|
lng: lng,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to send marker_hover_start event:", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("LiveView not connected, cannot send hover event");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.on("mouseout", () => {
|
||||||
|
console.log("Marker hover end for:", data.callsign);
|
||||||
|
// Check if LiveView is still connected before sending event
|
||||||
|
if (self.pushEvent && !self.isDestroyed) {
|
||||||
|
try {
|
||||||
|
self.pushEvent("marker_hover_end", {
|
||||||
|
id: data.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to send marker_hover_end event:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Mark historical markers for identification
|
// Mark historical markers for identification
|
||||||
|
|
|
||||||
4
assets/js/types/map.d.ts
vendored
4
assets/js/types/map.d.ts
vendored
|
|
@ -28,6 +28,9 @@ export interface LiveViewHookContext {
|
||||||
popupNavigationHandler?: (e: Event) => void;
|
popupNavigationHandler?: (e: Event) => void;
|
||||||
moveEndHandler?: () => void;
|
moveEndHandler?: () => void;
|
||||||
zoomEndHandler?: () => void;
|
zoomEndHandler?: () => void;
|
||||||
|
rfPathLines?: any[]; // Array of Leaflet polylines and markers for RF path visualization
|
||||||
|
trailLayer?: LayerGroup;
|
||||||
|
markerClusterGroup?: any;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +51,7 @@ export interface MarkerData {
|
||||||
callsign_group?: string;
|
callsign_group?: string;
|
||||||
symbol_html?: string;
|
symbol_html?: string;
|
||||||
openPopup?: boolean;
|
openPopup?: boolean;
|
||||||
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoundsData {
|
export interface BoundsData {
|
||||||
|
|
|
||||||
|
|
@ -424,8 +424,13 @@ defmodule Aprsme.Packets do
|
||||||
@impl true
|
@impl true
|
||||||
@spec get_recent_packets(map()) :: [struct()]
|
@spec get_recent_packets(map()) :: [struct()]
|
||||||
def get_recent_packets(opts \\ %{}) do
|
def get_recent_packets(opts \\ %{}) do
|
||||||
# Always limit to the last 24 hours for more symbol variety
|
# Use provided hours_back or default to 24 hours
|
||||||
opts_with_time = Map.put(opts, :hours_back, 24)
|
opts_with_time =
|
||||||
|
if Map.has_key?(opts, :hours_back) do
|
||||||
|
opts
|
||||||
|
else
|
||||||
|
Map.put(opts, :hours_back, 24)
|
||||||
|
end
|
||||||
|
|
||||||
opts_with_time
|
opts_with_time
|
||||||
|> QueryBuilder.recent_position_packets()
|
|> QueryBuilder.recent_position_packets()
|
||||||
|
|
@ -439,8 +444,9 @@ defmodule Aprsme.Packets do
|
||||||
@impl true
|
@impl true
|
||||||
@spec get_recent_packets_optimized(map()) :: [struct()]
|
@spec get_recent_packets_optimized(map()) :: [struct()]
|
||||||
def get_recent_packets_optimized(opts \\ %{}) do
|
def get_recent_packets_optimized(opts \\ %{}) do
|
||||||
# Always limit to the last 24 hours for more symbol variety
|
# Use hours_back from opts if provided, otherwise default to 24 hours
|
||||||
one_hour_ago = TimeUtils.one_day_ago()
|
hours_back = Map.get(opts, :hours_back, 24)
|
||||||
|
time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
|
||||||
limit = Map.get(opts, :limit, 200)
|
limit = Map.get(opts, :limit, 200)
|
||||||
offset = Map.get(opts, :offset, 0)
|
offset = Map.get(opts, :offset, 0)
|
||||||
|
|
||||||
|
|
@ -448,7 +454,7 @@ defmodule Aprsme.Packets do
|
||||||
base_query =
|
base_query =
|
||||||
from(p in Packet,
|
from(p in Packet,
|
||||||
where: p.has_position == true,
|
where: p.has_position == true,
|
||||||
where: p.received_at >= ^one_hour_ago
|
where: p.received_at >= ^time_ago
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply spatial and other filters BEFORE limiting
|
# Apply spatial and other filters BEFORE limiting
|
||||||
|
|
|
||||||
|
|
@ -752,13 +752,33 @@ defmodule AprsmeWeb.CoreComponents do
|
||||||
attr :class, :string, default: ""
|
attr :class, :string, default: ""
|
||||||
attr :variant, :atom, values: [:horizontal, :vertical], default: :horizontal
|
attr :variant, :atom, values: [:horizontal, :vertical], default: :horizontal
|
||||||
attr :current_user, :any, default: nil
|
attr :current_user, :any, default: nil
|
||||||
|
attr :map_state, :map, default: nil
|
||||||
|
attr :tracked_callsign, :string, default: ""
|
||||||
|
|
||||||
def navigation(assigns) do
|
def navigation(assigns) do
|
||||||
|
# Build home URL with map state if available
|
||||||
|
home_url =
|
||||||
|
if assigns[:map_state] do
|
||||||
|
base_url = "/?lat=#{assigns.map_state.lat}&lng=#{assigns.map_state.lng}&z=#{assigns.map_state.zoom}"
|
||||||
|
|
||||||
|
if assigns[:tracked_callsign] && assigns.tracked_callsign != "" do
|
||||||
|
base_url <> "&call=#{assigns.tracked_callsign}"
|
||||||
|
else
|
||||||
|
base_url
|
||||||
|
end
|
||||||
|
else
|
||||||
|
"/"
|
||||||
|
end
|
||||||
|
|
||||||
|
assigns = assign(assigns, :home_url, home_url)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<%= if @variant == :horizontal do %>
|
<%= if @variant == :horizontal do %>
|
||||||
<ul class="menu menu-horizontal px-1">
|
<ul class="menu menu-horizontal px-1">
|
||||||
<li>
|
<li>
|
||||||
<.link navigate="/" class="text-gray-900 hover:text-gray-700">{gettext("Home")}</.link>
|
<.link navigate={@home_url} class="text-gray-900 hover:text-gray-700">
|
||||||
|
{gettext("Home")}
|
||||||
|
</.link>
|
||||||
</li>
|
</li>
|
||||||
<li><.link navigate="/api" class="text-gray-900 hover:text-gray-700">API</.link></li>
|
<li><.link navigate="/api" class="text-gray-900 hover:text-gray-700">API</.link></li>
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -791,7 +811,11 @@ defmodule AprsmeWeb.CoreComponents do
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% else %>
|
<% else %>
|
||||||
<li><.link navigate="/" class="text-gray-900 hover:text-gray-700">{gettext("Home")}</.link></li>
|
<li>
|
||||||
|
<.link navigate={@home_url} class="text-gray-900 hover:text-gray-700">
|
||||||
|
{gettext("Home")}
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
<li><.link navigate="/api" class="text-gray-900 hover:text-gray-700">API</.link></li>
|
<li><.link navigate="/api" class="text-gray-900 hover:text-gray-700">API</.link></li>
|
||||||
<li>
|
<li>
|
||||||
<.link navigate="/about" class="text-gray-900 hover:text-gray-700">{gettext("About")}</.link>
|
<.link navigate="/about" class="text-gray-900 hover:text-gray-700">{gettext("About")}</.link>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
||||||
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2]
|
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2]
|
||||||
|
|
||||||
|
alias Aprsme.CachedQueries
|
||||||
alias Aprsme.GeoUtils
|
alias Aprsme.GeoUtils
|
||||||
alias Aprsme.Packets.Clustering
|
alias Aprsme.Packets.Clustering
|
||||||
alias AprsmeWeb.Endpoint
|
alias AprsmeWeb.Endpoint
|
||||||
|
|
@ -92,7 +93,7 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
# Check for IP geolocation in session
|
# Check for IP geolocation in session
|
||||||
|
|
||||||
# Check if URL params were explicitly provided (not just defaults)
|
# Check if URL params were explicitly provided (not just defaults)
|
||||||
has_explicit_url_params = params["lat"] || params["lng"] || params["z"]
|
has_explicit_url_params = !!(params["lat"] || params["lng"] || params["z"])
|
||||||
|
|
||||||
{map_center, map_zoom, should_skip_initial_url_update} =
|
{map_center, map_zoom, should_skip_initial_url_update} =
|
||||||
case session["ip_geolocation"] do
|
case session["ip_geolocation"] do
|
||||||
|
|
@ -118,9 +119,6 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
# Initialize the flag to track if initial historical load is completed
|
# Initialize the flag to track if initial historical load is completed
|
||||||
socket = assign(socket, initial_historical_completed: false)
|
socket = assign(socket, initial_historical_completed: false)
|
||||||
|
|
||||||
# Calculate initial bounds based on center and zoom level
|
|
||||||
initial_bounds = calculate_bounds_from_center_and_zoom(map_center, map_zoom)
|
|
||||||
|
|
||||||
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")
|
||||||
|
|
@ -129,12 +127,29 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
# Check for callsign parameter
|
# Check for callsign parameter
|
||||||
tracked_callsign = Map.get(params, "call", "")
|
tracked_callsign = Map.get(params, "call", "")
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{map_center, map_zoom}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{map_center, map_zoom}
|
||||||
|
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,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
map_ready: false,
|
map_ready: false,
|
||||||
map_bounds: initial_bounds,
|
map_bounds: initial_bounds,
|
||||||
map_center: map_center,
|
map_center: final_map_center,
|
||||||
map_zoom: map_zoom,
|
map_zoom: final_map_zoom,
|
||||||
should_skip_initial_url_update: should_skip_initial_url_update,
|
should_skip_initial_url_update: should_skip_initial_url_update,
|
||||||
visible_packets: %{},
|
visible_packets: %{},
|
||||||
historical_packets: %{},
|
historical_packets: %{},
|
||||||
|
|
@ -151,7 +166,7 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
all_packets: %{},
|
all_packets: %{},
|
||||||
station_popup_open: false,
|
station_popup_open: false,
|
||||||
initial_bounds_loaded: false,
|
initial_bounds_loaded: false,
|
||||||
needs_initial_historical_load: false
|
needs_initial_historical_load: tracked_callsign != ""
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -302,11 +317,9 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
def handle_event("map_ready", _params, socket) do
|
def handle_event("map_ready", _params, socket) do
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
# Mark map as ready and that we need to load historical packets
|
# Mark map as ready - preserve existing needs_initial_historical_load state
|
||||||
socket =
|
# (it's already set correctly in mount based on whether we're tracking a callsign)
|
||||||
socket
|
socket = assign(socket, map_ready: true)
|
||||||
|> assign(map_ready: true)
|
|
||||||
|> assign(needs_initial_historical_load: true)
|
|
||||||
|
|
||||||
# If we have non-default center coordinates (e.g., from geolocation), apply them now
|
# If we have non-default center coordinates (e.g., from geolocation), apply them now
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -334,6 +347,41 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
{:noreply, assign(socket, station_popup_open: true)}
|
{:noreply, assign(socket, station_popup_open: true)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("marker_hover_start", %{"id" => _id, "path" => path, "lat" => lat, "lng" => lng}, socket) do
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
Logger.info("RF path hover start - path: #{inspect(path)}")
|
||||||
|
|
||||||
|
# Parse the path to find digipeater/igate stations
|
||||||
|
path_stations = parse_rf_path(path)
|
||||||
|
Logger.info("Parsed path stations: #{inspect(path_stations)}")
|
||||||
|
|
||||||
|
# Query for positions of path stations
|
||||||
|
path_station_positions = get_path_station_positions(path_stations, socket)
|
||||||
|
Logger.info("Found #{length(path_station_positions)} station positions")
|
||||||
|
|
||||||
|
# Send event to draw the RF path lines
|
||||||
|
socket =
|
||||||
|
if length(path_station_positions) > 0 do
|
||||||
|
push_event(socket, "draw_rf_path", %{
|
||||||
|
station_lat: lat,
|
||||||
|
station_lng: lng,
|
||||||
|
path_stations: path_station_positions
|
||||||
|
})
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("marker_hover_end", _params, socket) do
|
||||||
|
# Clear the RF path lines
|
||||||
|
{:noreply, push_event(socket, "clear_rf_path", %{})}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("update_callsign", %{"callsign" => callsign}, socket) do
|
def handle_event("update_callsign", %{"callsign" => callsign}, socket) do
|
||||||
{:noreply, assign(socket, overlay_callsign: callsign)}
|
{:noreply, assign(socket, overlay_callsign: callsign)}
|
||||||
|
|
@ -1222,7 +1270,13 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
</svg>
|
</svg>
|
||||||
<span class="font-medium">{gettext("Navigation")}</span>
|
<span class="font-medium">{gettext("Navigation")}</span>
|
||||||
</div>
|
</div>
|
||||||
<.navigation variant={:vertical} class="text-sm" current_user={@current_user} />
|
<.navigation
|
||||||
|
variant={:vertical}
|
||||||
|
class="text-sm"
|
||||||
|
current_user={@current_user}
|
||||||
|
map_state={%{lat: @map_center.lat, lng: @map_center.lng, zoom: @map_zoom}}
|
||||||
|
tracked_callsign={@tracked_callsign}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Deployment Information -->
|
<!-- Deployment Information -->
|
||||||
|
|
@ -1461,6 +1515,7 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
"timestamp" => DateTime.to_unix(packet.received_at || DateTime.utc_now(), :millisecond),
|
"timestamp" => DateTime.to_unix(packet.received_at || DateTime.utc_now(), :millisecond),
|
||||||
"historical" => !is_most_recent,
|
"historical" => !is_most_recent,
|
||||||
"is_most_recent_for_callsign" => is_most_recent,
|
"is_most_recent_for_callsign" => is_most_recent,
|
||||||
|
"path" => packet.path || "",
|
||||||
"popup" => build_simple_popup(packet, has_weather)
|
"popup" => build_simple_popup(packet, has_weather)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -1671,6 +1726,10 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
Map.put(params, :callsign, socket.assigns.tracked_callsign)
|
Map.put(params, :callsign, socket.assigns.tracked_callsign)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Use the historical_hours setting from the UI dropdown
|
||||||
|
historical_hours = String.to_integer(socket.assigns.historical_hours || "1")
|
||||||
|
params = Map.put(params, :hours_back, historical_hours)
|
||||||
|
|
||||||
Aprsme.CachedQueries.get_recent_packets_cached(params)
|
Aprsme.CachedQueries.get_recent_packets_cached(params)
|
||||||
else
|
else
|
||||||
# Fallback for testing
|
# Fallback for testing
|
||||||
|
|
@ -1993,4 +2052,77 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse RF path string to extract digipeater/igate callsigns
|
||||||
|
defp parse_rf_path(path) when is_binary(path) do
|
||||||
|
# Split by comma and filter out empty strings
|
||||||
|
path
|
||||||
|
|> String.split(",")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
# Skip TCPIP entries and qA* entries (but preserve the actual digipeater/igate callsigns)
|
||||||
|
|> Enum.reject(&String.contains?(&1, "TCPIP"))
|
||||||
|
|> Enum.reject(&String.starts_with?(&1, "qA"))
|
||||||
|
|> Enum.map(fn callsign ->
|
||||||
|
# Remove any asterisk (used to mark heard stations) and WIDE patterns
|
||||||
|
callsign
|
||||||
|
|> String.replace("*", "")
|
||||||
|
|> String.trim()
|
||||||
|
end)
|
||||||
|
# Filter out WIDE patterns (both WIDE1-1 and WIDE1 forms)
|
||||||
|
|> Enum.reject(fn callsign ->
|
||||||
|
String.starts_with?(callsign, "WIDE")
|
||||||
|
end)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_path_station_positions(_, _), do: []
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,8 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
32
|
32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
path_value = get_packet_field(packet, :path, "")
|
||||||
|
|
||||||
%{
|
%{
|
||||||
"id" => packet_id,
|
"id" => packet_id,
|
||||||
"callsign" => packet_info.callsign,
|
"callsign" => packet_info.callsign,
|
||||||
|
|
@ -276,7 +278,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
||||||
"lat" => to_float(lat),
|
"lat" => to_float(lat),
|
||||||
"lng" => to_float(lon),
|
"lng" => to_float(lon),
|
||||||
"data_type" => to_string(get_packet_field(packet, :data_type, "unknown")),
|
"data_type" => to_string(get_packet_field(packet, :data_type, "unknown")),
|
||||||
"path" => get_packet_field(packet, :path, ""),
|
"path" => path_value,
|
||||||
"comment" => packet_info.comment,
|
"comment" => packet_info.comment,
|
||||||
"data_extended" => packet_info.safe_data_extended || %{},
|
"data_extended" => packet_info.safe_data_extended || %{},
|
||||||
"symbol_table_id" => packet_info.symbol_table_id,
|
"symbol_table_id" => packet_info.symbol_table_id,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ defmodule AprsmeWeb.TimeHelpers do
|
||||||
@doc """
|
@doc """
|
||||||
Returns a human-readable string for how long ago the given DateTime was.
|
Returns a human-readable string for how long ago the given DateTime was.
|
||||||
"""
|
"""
|
||||||
|
def time_ago_in_words(nil), do: gettext("unknown time") <> " " <> gettext("ago")
|
||||||
|
|
||||||
|
def time_ago_in_words(%NaiveDateTime{} = naive_datetime) do
|
||||||
|
# Convert NaiveDateTime to DateTime assuming UTC
|
||||||
|
datetime = DateTime.from_naive!(naive_datetime, "Etc/UTC")
|
||||||
|
time_ago_in_words(datetime)
|
||||||
|
end
|
||||||
|
|
||||||
def time_ago_in_words(datetime) do
|
def time_ago_in_words(datetime) do
|
||||||
now = DateTime.utc_now()
|
now = DateTime.utc_now()
|
||||||
diff_seconds = DateTime.diff(now, datetime, :second)
|
diff_seconds = DateTime.diff(now, datetime, :second)
|
||||||
|
|
|
||||||
281
test/aprsme_web/live/map_live/rf_path_test.exs
Normal file
281
test/aprsme_web/live/map_live/rf_path_test.exs
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
defmodule AprsmeWeb.MapLive.RfPathTest do
|
||||||
|
use AprsmeWeb.ConnCase
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Aprsme.Packet
|
||||||
|
alias Aprsme.Repo
|
||||||
|
alias AprsmeWeb.MapLive.Index
|
||||||
|
|
||||||
|
describe "RF path parsing" do
|
||||||
|
test "parses RF paths correctly", %{conn: conn} do
|
||||||
|
# Test the parse_rf_path function indirectly through module
|
||||||
|
# Since it's a private function, we test the behavior
|
||||||
|
|
||||||
|
{:ok, _digi1} =
|
||||||
|
Repo.insert(%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"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
# Send a marker_hover_start event
|
||||||
|
render_hook(view, "marker_hover_start", %{
|
||||||
|
"id" => "test-1",
|
||||||
|
"path" => "K5GVL-10*,WIDE1-1,WIDE2-1",
|
||||||
|
"lat" => 33.2837,
|
||||||
|
"lng" => -96.5728
|
||||||
|
})
|
||||||
|
|
||||||
|
# The function should parse the path and try to find positions
|
||||||
|
# We can't directly test the private function, but we can verify no errors occur
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
|
||||||
|
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{
|
||||||
|
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"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _station2} =
|
||||||
|
Repo.insert(%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"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
# Test path with multiple digipeaters
|
||||||
|
render_hook(view, "marker_hover_start", %{
|
||||||
|
"id" => "test-complex",
|
||||||
|
"path" => "N5ABC,WB5DEF-1,WIDE2-1,qAR,K5VOM-10",
|
||||||
|
"lat" => 33.2837,
|
||||||
|
"lng" => -96.5728
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should handle complex paths without errors
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles paths with asterisks correctly", %{conn: conn} do
|
||||||
|
{:ok, _station} =
|
||||||
|
Repo.insert(%Packet{
|
||||||
|
sender: "WA5VHU-8",
|
||||||
|
base_callsign: "WA5VHU",
|
||||||
|
ssid: "8",
|
||||||
|
lat: Decimal.new("33.1500"),
|
||||||
|
lon: Decimal.new("-96.5500"),
|
||||||
|
has_position: true,
|
||||||
|
received_at: DateTime.truncate(DateTime.utc_now(), :second),
|
||||||
|
data_type: "position"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
# Test path with asterisks (used digipeaters)
|
||||||
|
render_hook(view, "marker_hover_start", %{
|
||||||
|
"id" => "test-asterisk",
|
||||||
|
"path" => "WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10",
|
||||||
|
"lat" => 33.2837,
|
||||||
|
"lng" => -96.5728
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should handle asterisks without errors
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filters out TCPIP from paths", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
# Send a hover event with TCPIP in path
|
||||||
|
render_hook(view, "marker_hover_start", %{
|
||||||
|
"id" => "test-2",
|
||||||
|
"path" => "TCPIP*,qAC,T2TEXAS",
|
||||||
|
"lat" => 33.2837,
|
||||||
|
"lng" => -96.5728
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should not crash and should filter out TCPIP
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles empty paths gracefully", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
# Send hover event with empty path
|
||||||
|
render_hook(view, "marker_hover_start", %{"id" => "test-3", "path" => "", "lat" => 33.2837, "lng" => -96.5728})
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
|
||||||
|
test "marker hover end event works", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
# Send hover end event
|
||||||
|
render_hook(view, "marker_hover_end", %{"id" => "test-1"})
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "path station position queries" do
|
||||||
|
setup do
|
||||||
|
# Create test packets with positions
|
||||||
|
{:ok, _digi1} =
|
||||||
|
Repo.insert(%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"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _digi2} =
|
||||||
|
Repo.insert(%Packet{
|
||||||
|
sender: "N5TXZ-10",
|
||||||
|
base_callsign: "N5TXZ",
|
||||||
|
ssid: "10",
|
||||||
|
lat: Decimal.new("33.2000"),
|
||||||
|
lon: Decimal.new("-96.5000"),
|
||||||
|
has_position: true,
|
||||||
|
received_at: DateTime.truncate(DateTime.utc_now(), :second),
|
||||||
|
data_type: "position"
|
||||||
|
})
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds positions for digipeaters in path", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
# Send hover event with known digipeaters
|
||||||
|
render_hook(view, "marker_hover_start", %{
|
||||||
|
"id" => "test-4",
|
||||||
|
"path" => "K5GVL-10*,N5TXZ-10",
|
||||||
|
"lat" => 33.2837,
|
||||||
|
"lng" => -96.5728
|
||||||
|
})
|
||||||
|
|
||||||
|
# The function should find these stations
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "RF path with stations outside bounds" do
|
||||||
|
setup do
|
||||||
|
# Create test packets - one inside a small bounds, one outside
|
||||||
|
# Station inside typical Texas bounds
|
||||||
|
{:ok, _inside_station} =
|
||||||
|
Repo.insert(%Packet{
|
||||||
|
sender: "K5INSIDE-10",
|
||||||
|
base_callsign: "K5INSIDE",
|
||||||
|
ssid: "10",
|
||||||
|
# Inside Texas area
|
||||||
|
lat: Decimal.new("32.5000"),
|
||||||
|
# Inside Texas area
|
||||||
|
lon: Decimal.new("-96.5000"),
|
||||||
|
has_position: true,
|
||||||
|
received_at: DateTime.truncate(DateTime.utc_now(), :second),
|
||||||
|
data_type: "position"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Station outside bounds (way outside in Boston area)
|
||||||
|
{:ok, _outside_station} =
|
||||||
|
Repo.insert(%Packet{
|
||||||
|
sender: "W1OUTSIDE-10",
|
||||||
|
base_callsign: "W1OUTSIDE",
|
||||||
|
ssid: "10",
|
||||||
|
# Boston area - far from Texas
|
||||||
|
lat: Decimal.new("42.0000"),
|
||||||
|
# Boston area - far from Texas
|
||||||
|
lon: Decimal.new("-71.0000"),
|
||||||
|
has_position: true,
|
||||||
|
received_at: DateTime.truncate(DateTime.utc_now(), :second),
|
||||||
|
data_type: "position"
|
||||||
|
})
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes path stations outside map bounds", %{conn: conn} 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_hook(view, "bounds_changed", %{"bounds" => bounds})
|
||||||
|
|
||||||
|
# Now hover over a marker with a path that includes both inside and outside stations
|
||||||
|
render_hook(view, "marker_hover_start", %{
|
||||||
|
"id" => "test-bounds",
|
||||||
|
# Both inside and outside stations
|
||||||
|
"path" => "K5INSIDE-10*,W1OUTSIDE-10",
|
||||||
|
"lat" => 32.5,
|
||||||
|
"lng" => -96.5
|
||||||
|
})
|
||||||
|
|
||||||
|
# With the fix, both stations should be found regardless of bounds
|
||||||
|
# The test passes if no errors occur during the hover event
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds stations regardless of bounds constraints", %{conn: conn} 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_hook(view, "bounds_changed", %{"bounds" => bounds})
|
||||||
|
|
||||||
|
# Hover with path containing stations outside these bounds
|
||||||
|
render_hook(view, "marker_hover_start", %{
|
||||||
|
"id" => "test-no-bounds",
|
||||||
|
"path" => "K5INSIDE-10,W1OUTSIDE-10",
|
||||||
|
"lat" => 32.5,
|
||||||
|
"lng" => -96.5
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should work without errors - stations found regardless of bounds
|
||||||
|
assert view.module == Index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue