From 14fa16df6d0305881262e650ecfaf31e07ea25f6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 19 Feb 2026 15:41:23 -0600 Subject: [PATCH] Fix RF path visualization for hovering over track points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/js/features/trail_manager.ts | 57 +++++++++- assets/js/map.ts | 49 +++++++- lib/aprsme_web/live/map_live/rf_path.ex | 54 +++++++-- .../live/map_live/rf_path_unit_test.exs | 106 ++++++++++++++++++ 4 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 test/aprsme_web/live/map_live/rf_path_unit_test.exs diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index b68790a..016691e 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -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); diff --git a/assets/js/map.ts b/assets/js/map.ts index 9e27fba..9c0d640 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -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(); @@ -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, ); } } diff --git a/lib/aprsme_web/live/map_live/rf_path.ex b/lib/aprsme_web/live/map_live/rf_path.ex index 3f90685..e4ea53e 100644 --- a/lib/aprsme_web/live/map_live/rf_path.ex +++ b/lib/aprsme_web/live/map_live/rf_path.ex @@ -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 diff --git a/test/aprsme_web/live/map_live/rf_path_unit_test.exs b/test/aprsme_web/live/map_live/rf_path_unit_test.exs new file mode 100644 index 0000000..da349e1 --- /dev/null +++ b/test/aprsme_web/live/map_live/rf_path_unit_test.exs @@ -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