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:
parent
91ced547b1
commit
14fa16df6d
4 changed files with 249 additions and 17 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
test/aprsme_web/live/map_live/rf_path_unit_test.exs
Normal file
106
test/aprsme_web/live/map_live/rf_path_unit_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue