Fix RF path visualization for hovering over track points

parse_rf_path was filtering out all paths containing qA* constructs,
which is virtually every APRS-IS packet. The qA* element delimits the
RF path from the APRS-IS metadata — it should be split on, not
discarded. Now properly extracts digipeaters (before qA*) and the
igate (after qA*), while filtering APRS aliases like WIDE/RELAY/TRACE.

Also adds hover handlers to trail polylines so hovering anywhere on a
call's track shows the RF path, not just on the dot markers.
This commit is contained in:
Graham McIntire 2026-02-19 15:41:23 -06:00
parent 91ced547b1
commit 14fa16df6d
No known key found for this signature in database
4 changed files with 249 additions and 17 deletions

View file

@ -14,6 +14,7 @@ export interface PositionHistory {
lat: number;
lng: number;
timestamp: number;
path?: string;
}
export interface TrailState {
@ -51,12 +52,21 @@ export class TrailManager {
private minMovementThreshold: number = 0.1; // km - minimum total distance to show a trail
private maxHopDistance: number = 10; // km - max distance between consecutive points before breaking
private minSegmentPoints: number = 3; // minimum points in a segment to draw it
private onTrailHover?: (lat: number, lng: number, path: string) => void;
private onTrailHoverEnd?: () => void;
constructor(trailLayer: LayerGroup, trailDuration: number = 60 * 60 * 1000) {
constructor(
trailLayer: LayerGroup,
trailDuration: number = 60 * 60 * 1000,
onTrailHover?: (lat: number, lng: number, path: string) => void,
onTrailHoverEnd?: () => void,
) {
this.trailLayer = trailLayer;
this.trails = new Map();
this.showTrails = true;
this.trailDuration = trailDuration;
this.onTrailHover = onTrailHover;
this.onTrailHoverEnd = onTrailHoverEnd;
// Ensure trail layer is added to the map and visible
if (this.trailLayer && this.trailLayer.bringToFront) {
@ -113,6 +123,7 @@ export class TrailManager {
lng: number,
timestamp: number,
isHistoricalDot: boolean = false,
path?: string,
) {
if (!this.showTrails) return;
@ -165,7 +176,7 @@ export class TrailManager {
if (!existingPos) {
// Add new position
trailState.positions.push({ lat, lng, timestamp });
trailState.positions.push({ lat, lng, timestamp, path });
// Sort positions by timestamp to maintain chronological order
trailState.positions.sort((a, b) => a.timestamp - b.timestamp);
@ -405,6 +416,28 @@ export class TrailManager {
drawableSegments,
polylineOptions,
).addTo(this.trailLayer);
// Attach hover handlers for RF path visualization
if (this.onTrailHover && this.onTrailHoverEnd) {
const positions = trailState.positions;
const hoverCb = this.onTrailHover;
const hoverEndCb = this.onTrailHoverEnd;
trailState.trail.on("mouseover", (e: L.LeafletMouseEvent) => {
const nearest = this.findNearestPositionWithPath(
e.latlng.lat,
e.latlng.lng,
positions,
);
if (nearest) {
hoverCb(nearest.lat, nearest.lng, nearest.path!);
}
});
trailState.trail.on("mouseout", () => {
hoverEndCb();
});
}
} catch (error) {
console.error(
"Error creating trail polyline for",
@ -419,6 +452,26 @@ export class TrailManager {
}
}
private findNearestPositionWithPath(
lat: number,
lng: number,
positions: PositionHistory[],
): PositionHistory | null {
let nearest: PositionHistory | null = null;
let minDist = Infinity;
for (const pos of positions) {
if (!pos.path || pos.path.trim() === "") continue;
const dist = (pos.lat - lat) ** 2 + (pos.lng - lng) ** 2;
if (dist < minDist) {
minDist = dist;
nearest = pos;
}
}
return nearest;
}
removeTrail(markerId: string) {
const baseCallsign = this.extractBaseCallsign(markerId);
const trailState = this.trails.get(baseCallsign);

View file

@ -409,9 +409,48 @@ let MapAPRSMap = {
console.error("Error creating heat layer:", error);
}
// Create trail layer and manager
// Create trail layer and manager with RF path hover callbacks
const trailLayer = L.layerGroup().addTo(self.map);
self.trailManager = new TrailManager(trailLayer);
self.trailManager = new TrailManager(
trailLayer,
60 * 60 * 1000,
// onTrailHover: send RF path visualization event to backend
(lat: number, lng: number, path: string) => {
if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
try {
self.pushEvent.call(self, "marker_hover_start", {
id: "trail_hover",
callsign: "",
path: path,
lat: lat,
lng: lng,
});
} catch (error) {
console.warn("Failed to send trail hover event:", error);
}
}
},
// onTrailHoverEnd: clear RF path lines
() => {
if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
try {
self.pushEvent.call(self, "marker_hover_end", {
id: "trail_hover",
});
} catch (error) {
console.warn("Failed to send trail hover end event:", error);
}
}
},
);
// Track marker states to prevent unnecessary operations
self.markerStates = new Map<string, MarkerState>();
@ -1586,6 +1625,7 @@ let MapAPRSMap = {
lng,
timestamp,
isHistoricalDot,
data.path,
);
}
@ -1677,8 +1717,7 @@ let MapAPRSMap = {
});
// 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")) {
if (data.path && data.path.trim() !== "") {
marker.on("mouseover", () => {
// Check if LiveView is still connected before sending event
if (
@ -1763,6 +1802,7 @@ let MapAPRSMap = {
lng,
timestamp,
isHistoricalDot,
data.path,
);
}
@ -1875,6 +1915,7 @@ let MapAPRSMap = {
lng,
timestamp,
isHistoricalDot,
data.path,
);
}
}

View file

@ -6,28 +6,35 @@ defmodule AprsmeWeb.MapLive.RfPath do
alias Aprsme.Packets
alias AprsmeWeb.MapLive.Utils
# APRS aliases that are not real stations
@path_aliases ~w(WIDE WIDE1 WIDE2 WIDE3 WIDE4 WIDE5 WIDE6 WIDE7
RELAY TRACE ECHO HOP DMR)
@doc """
Parse RF path string to extract digipeater/igate stations.
Excludes APRS-IS (Internet) paths that contain TCPIP.
APRS-IS path indicators:
- TCPIP: Packet came via Internet connection
- NOGATE: Should not be gated to RF
- qA*: Various APRS-IS q-constructs (qAC, qAS, qAR, etc.)
APRS-IS path format: [RF digipeaters...],qA[R/S/O/C],[igate]
These are not actual RF digipeaters, so we don't show path lines for them.
- Everything before the qA* element = RF path (digipeaters)
- The element after qA* = the igate that received/gated the packet
- TCPIP/NOGATE paths are Internet-only and return empty
- WIDE*, RELAY, TRACE, etc. are aliases, not real stations
"""
@spec parse_rf_path(binary()) :: list(binary())
def parse_rf_path(""), do: []
def parse_rf_path(path) when is_binary(path) do
# Skip APRS-IS paths - these are Internet-only, not RF
if String.contains?(path, "TCPIP") or String.contains?(path, "NOGATE") or String.contains?(path, "qA") do
if String.contains?(path, "TCPIP") or String.contains?(path, "NOGATE") do
[]
else
path
|> String.split(",")
|> Enum.map(&String.trim/1)
elements = path |> String.split(",") |> Enum.map(&String.trim/1)
{rf_elements, igate_elements} = split_at_q_construct(elements)
(rf_elements ++ igate_elements)
|> Enum.map(&extract_callsign_from_path_element/1)
|> Enum.filter(&(&1 != ""))
|> Enum.reject(&path_alias?/1)
|> Enum.uniq()
end
end
@ -47,6 +54,31 @@ defmodule AprsmeWeb.MapLive.RfPath do
|> Enum.filter(& &1)
end
defp split_at_q_construct(elements) do
q_index = Enum.find_index(elements, &String.starts_with?(&1, "qA"))
case q_index do
nil ->
# No q-construct found — treat all elements as RF path
{elements, []}
idx ->
rf_elements = Enum.slice(elements, 0, idx)
# The element after qA* is the igate
igate_elements = Enum.slice(elements, (idx + 1)..-1//1)
{rf_elements, igate_elements}
end
end
defp path_alias?(callsign) do
# Check base callsign (without SSID) against known aliases
base = callsign |> String.split("-") |> hd()
# Also filter out aliases with numeric suffixes like WIDE1, WIDE2, TRACE3, HOP7
stripped = String.replace(base, ~r/\d+$/, "")
base in @path_aliases or stripped in @path_aliases
end
defp extract_callsign_from_path_element(element) do
# Remove asterisk (used flag) and extract callsign
element

View file

@ -0,0 +1,106 @@
defmodule AprsmeWeb.MapLive.RfPathUnitTest do
use ExUnit.Case, async: true
alias AprsmeWeb.MapLive.RfPath
describe "parse_rf_path/1" do
test "extracts digipeater and igate from qAR path" do
# qAR = packet received by igate from RF
# Format: [digipeaters...],qAR,[igate]
result = RfPath.parse_rf_path("1BATES-5*,WIDE2-1,qAR,LU9EHU-10")
assert "1BATES-5" in result
assert "LU9EHU-10" in result
end
test "extracts igate from simple qAR path with no digipeaters" do
result = RfPath.parse_rf_path("2E0FKC-10*,qAR,M0SDM-10")
assert "2E0FKC-10" in result
assert "M0SDM-10" in result
end
test "extracts digipeaters and igate from qAS path" do
# qAS = packet sent to APRS-IS server
result = RfPath.parse_rf_path("1BATES-5,LU7AMC-15*,WIDE1,qAS,LU4DQ-1")
assert "1BATES-5" in result
assert "LU7AMC-15" in result
assert "LU4DQ-1" in result
end
test "extracts digipeaters and igate from qAO path" do
# qAO = gated by positioned igate
result = RfPath.parse_rf_path("1BATES-5*,LU9EBZ,qAO,LU4AGC-10")
assert "1BATES-5" in result
assert "LU9EBZ" in result
assert "LU4AGC-10" in result
end
test "filters out WIDE aliases" do
result = RfPath.parse_rf_path("K5GVL-10*,WIDE1-1,WIDE2-1,qAR,N5TXZ-10")
refute Enum.any?(result, &String.starts_with?(&1, "WIDE"))
assert "K5GVL-10" in result
assert "N5TXZ-10" in result
end
test "filters out RELAY, TRACE, and other aliases" do
result = RfPath.parse_rf_path("KC5ABC-9,RELAY,TRACE3-3,ECHO,HOP7-7,qAR,K5VOM-10")
refute "RELAY" in result
refute "ECHO" in result
assert "KC5ABC-9" in result
assert "K5VOM-10" in result
end
test "strips asterisks from used digipeater flags" do
result = RfPath.parse_rf_path("WA5VHU-8*,WIDE1*,qAR,K5VOM-10")
assert "WA5VHU-8" in result
refute "WA5VHU-8*" in result
end
test "returns empty for TCPIP paths" do
assert RfPath.parse_rf_path("TCPIP*,qAC,T2TEXAS") == []
end
test "returns empty for NOGATE paths" do
assert RfPath.parse_rf_path("NOGATE,qAC,SERVER") == []
end
test "returns empty for empty string" do
assert RfPath.parse_rf_path("") == []
end
test "returns empty for nil" do
assert RfPath.parse_rf_path(nil) == []
end
test "handles path with numeric-only prefix elements" do
# Paths like "-1,-2,qAR,DB0FTS-10" have non-callsign elements
result = RfPath.parse_rf_path("-1,-2,qAR,DB0FTS-10")
assert "DB0FTS-10" in result
refute "-1" in result
refute "-2" in result
end
test "handles path with only igate (no RF digipeaters)" do
result = RfPath.parse_rf_path("-1,qAO,BH4CRV-15")
assert "BH4CRV-15" in result
refute "-1" in result
end
test "returns unique callsigns" do
result = RfPath.parse_rf_path("K5ABC-10*,K5ABC-10,qAR,K5ABC-10")
assert length(Enum.uniq(result)) == length(result)
end
test "handles complex multi-hop path" do
result = RfPath.parse_rf_path("3A2ARM-10,WIDE1*,qAR,3A2MT-10")
assert "3A2ARM-10" in result
assert "3A2MT-10" in result
refute Enum.any?(result, &String.starts_with?(&1, "WIDE"))
end
test "handles DMR paths" do
result = RfPath.parse_rf_path("5B4AIE,DMR*,qAR,5B4AIE")
assert "5B4AIE" in result
refute "DMR" in result
end
end
end