main map improvements

This commit is contained in:
Graham McIntire 2025-07-13 14:58:13 -05:00
parent 532a81c0b4
commit fa89712587
No known key found for this signature in database
8 changed files with 590 additions and 21 deletions

View file

@ -271,6 +271,9 @@ let MapAPRSMap = {
// Track marker states to prevent unnecessary operations
self.markerStates = new Map<string, MarkerState>();
// Store RF path lines for hover visualization
self.rfPathLines = [];
// Force initial size calculation
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
self.handleEvent("filter_markers_by_bounds", (data: { bounds: BoundsData }) => {
if (data.bounds) {
@ -1156,6 +1229,45 @@ let MapAPRSMap = {
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

View file

@ -28,6 +28,9 @@ export interface LiveViewHookContext {
popupNavigationHandler?: (e: Event) => void;
moveEndHandler?: () => void;
zoomEndHandler?: () => void;
rfPathLines?: any[]; // Array of Leaflet polylines and markers for RF path visualization
trailLayer?: LayerGroup;
markerClusterGroup?: any;
[key: string]: any;
}
@ -48,6 +51,7 @@ export interface MarkerData {
callsign_group?: string;
symbol_html?: string;
openPopup?: boolean;
path?: string;
}
export interface BoundsData {

View file

@ -424,8 +424,13 @@ defmodule Aprsme.Packets do
@impl true
@spec get_recent_packets(map()) :: [struct()]
def get_recent_packets(opts \\ %{}) do
# Always limit to the last 24 hours for more symbol variety
opts_with_time = Map.put(opts, :hours_back, 24)
# Use provided hours_back or default to 24 hours
opts_with_time =
if Map.has_key?(opts, :hours_back) do
opts
else
Map.put(opts, :hours_back, 24)
end
opts_with_time
|> QueryBuilder.recent_position_packets()
@ -439,8 +444,9 @@ defmodule Aprsme.Packets do
@impl true
@spec get_recent_packets_optimized(map()) :: [struct()]
def get_recent_packets_optimized(opts \\ %{}) do
# Always limit to the last 24 hours for more symbol variety
one_hour_ago = TimeUtils.one_day_ago()
# Use hours_back from opts if provided, otherwise default to 24 hours
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)
offset = Map.get(opts, :offset, 0)
@ -448,7 +454,7 @@ defmodule Aprsme.Packets do
base_query =
from(p in Packet,
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

View file

@ -752,13 +752,33 @@ defmodule AprsmeWeb.CoreComponents do
attr :class, :string, default: ""
attr :variant, :atom, values: [:horizontal, :vertical], default: :horizontal
attr :current_user, :any, default: nil
attr :map_state, :map, default: nil
attr :tracked_callsign, :string, default: ""
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"""
<%= if @variant == :horizontal do %>
<ul class="menu menu-horizontal px-1">
<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><.link navigate="/api" class="text-gray-900 hover:text-gray-700">API</.link></li>
<li>
@ -791,7 +811,11 @@ defmodule AprsmeWeb.CoreComponents do
<% end %>
</ul>
<% 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="/about" class="text-gray-900 hover:text-gray-700">{gettext("About")}</.link>

View file

@ -8,6 +8,7 @@ defmodule AprsmeWeb.MapLive.Index do
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2]
alias Aprsme.CachedQueries
alias Aprsme.GeoUtils
alias Aprsme.Packets.Clustering
alias AprsmeWeb.Endpoint
@ -92,7 +93,7 @@ defmodule AprsmeWeb.MapLive.Index do
# 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"]
has_explicit_url_params = !!(params["lat"] || params["lng"] || params["z"])
{map_center, map_zoom, should_skip_initial_url_update} =
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
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
Endpoint.subscribe("aprs_messages")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
@ -129,12 +127,29 @@ defmodule AprsmeWeb.MapLive.Index do
# Check for callsign parameter
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,
assign(socket,
map_ready: false,
map_bounds: initial_bounds,
map_center: map_center,
map_zoom: map_zoom,
map_center: final_map_center,
map_zoom: final_map_zoom,
should_skip_initial_url_update: should_skip_initial_url_update,
visible_packets: %{},
historical_packets: %{},
@ -151,7 +166,7 @@ defmodule AprsmeWeb.MapLive.Index do
all_packets: %{},
station_popup_open: false,
initial_bounds_loaded: false,
needs_initial_historical_load: false
needs_initial_historical_load: tracked_callsign != ""
)}
end
@ -302,11 +317,9 @@ defmodule AprsmeWeb.MapLive.Index do
def handle_event("map_ready", _params, socket) do
require Logger
# Mark map as ready and that we need to load historical packets
socket =
socket
|> assign(map_ready: true)
|> assign(needs_initial_historical_load: true)
# Mark map as ready - preserve existing needs_initial_historical_load state
# (it's already set correctly in mount based on whether we're tracking a callsign)
socket = assign(socket, map_ready: true)
# If we have non-default center coordinates (e.g., from geolocation), apply them now
socket =
@ -334,6 +347,41 @@ defmodule AprsmeWeb.MapLive.Index do
{:noreply, assign(socket, station_popup_open: true)}
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
def handle_event("update_callsign", %{"callsign" => callsign}, socket) do
{:noreply, assign(socket, overlay_callsign: callsign)}
@ -1222,7 +1270,13 @@ defmodule AprsmeWeb.MapLive.Index do
</svg>
<span class="font-medium">{gettext("Navigation")}</span>
</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>
<!-- Deployment Information -->
@ -1461,6 +1515,7 @@ defmodule AprsmeWeb.MapLive.Index do
"timestamp" => DateTime.to_unix(packet.received_at || DateTime.utc_now(), :millisecond),
"historical" => !is_most_recent,
"is_most_recent_for_callsign" => is_most_recent,
"path" => packet.path || "",
"popup" => build_simple_popup(packet, has_weather)
}
end
@ -1671,6 +1726,10 @@ defmodule AprsmeWeb.MapLive.Index do
Map.put(params, :callsign, socket.assigns.tracked_callsign)
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)
else
# Fallback for testing
@ -1993,4 +2052,77 @@ defmodule AprsmeWeb.MapLive.Index do
socket
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

View file

@ -268,6 +268,8 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
32
)
path_value = get_packet_field(packet, :path, "")
%{
"id" => packet_id,
"callsign" => packet_info.callsign,
@ -276,7 +278,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
"lat" => to_float(lat),
"lng" => to_float(lon),
"data_type" => to_string(get_packet_field(packet, :data_type, "unknown")),
"path" => get_packet_field(packet, :path, ""),
"path" => path_value,
"comment" => packet_info.comment,
"data_extended" => packet_info.safe_data_extended || %{},
"symbol_table_id" => packet_info.symbol_table_id,

View file

@ -7,6 +7,14 @@ defmodule AprsmeWeb.TimeHelpers do
@doc """
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
now = DateTime.utc_now()
diff_seconds = DateTime.diff(now, datetime, :second)

View 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