- Session cookie: add encryption_salt and secure flag to endpoint - CSP: remove unsafe-eval from script-src directive - /metrics: wrap PromEx endpoint behind rate-limited pipeline - LiveView signing_salt: move to env var in production config - LiveView performance: defer heavy aggregation queries past initial mount - SQL: replace correlated subquery with DISTINCT ON in get_heard_by_stations
717 lines
25 KiB
Elixir
717 lines
25 KiB
Elixir
defmodule AprsmeWeb.InfoLive.Show do
|
|
@moduledoc false
|
|
use AprsmeWeb, :live_view
|
|
use Gettext, backend: AprsmeWeb.Gettext
|
|
|
|
import AprsmeWeb.Components.InfoMapComponent
|
|
import Ecto.Query
|
|
import Phoenix.HTML, only: [raw: 1]
|
|
|
|
alias Aprsme.Callsign
|
|
alias Aprsme.EncodingUtils
|
|
alias Aprsme.GeoUtils
|
|
alias Aprsme.Packets
|
|
alias Aprsme.Repo
|
|
alias AprsmeWeb.AprsSymbol
|
|
alias AprsmeWeb.Live.SharedPacketHandler
|
|
alias AprsmeWeb.MapLive.PacketUtils
|
|
|
|
require Logger
|
|
|
|
@neighbor_limit 10
|
|
|
|
# APRS Q-construct descriptions (APRS-IS codes)
|
|
@q_constructs %{
|
|
"qAC" => "qAC (APRS-IS connection)",
|
|
"qAO" => "qAO (APRS-IS origin)",
|
|
"qAR" => "qAR (APRS-IS relay)",
|
|
"qAS" => "qAS (APRS-IS server)",
|
|
"qAX" => "qAX (APRS-IS client)",
|
|
"qAY" => "qAY (APRS-IS gateway)",
|
|
"qAZ" => "qAZ (APRS-IS zone)",
|
|
"qBU" => "qBU (APRS-IS user)",
|
|
"qBV" => "qBV (APRS-IS vendor)",
|
|
"qBW" => "qBW (APRS-IS web)",
|
|
"qBX" => "qBX (APRS-IS experimental)",
|
|
"qBY" => "qBY (APRS-IS Y2K)",
|
|
"qBZ" => "qBZ (APRS-IS Zulu)",
|
|
"qCA" => "qCA (APRS-IS client application)",
|
|
"qCB" => "qCB (APRS-IS client browser)",
|
|
"qCC" => "qCC (APRS-IS client console)",
|
|
"qCD" => "qCD (APRS-IS client daemon)",
|
|
"qCE" => "qCE (APRS-IS client editor)",
|
|
"qCF" => "qCF (APRS-IS client filter)",
|
|
"qCG" => "qCG (APRS-IS client gateway)",
|
|
"qCH" => "qCH (APRS-IS client host)",
|
|
"qCI" => "qCI (APRS-IS client interface)",
|
|
"qCJ" => "qCJ (APRS-IS client java)",
|
|
"qCK" => "qCK (APRS-IS client kernel)",
|
|
"qCL" => "qCL (APRS-IS client library)",
|
|
"qCM" => "qCM (APRS-IS client module)",
|
|
"qCN" => "qCN (APRS-IS client network)",
|
|
"qCO" => "qCO (APRS-IS client object)",
|
|
"qCP" => "qCP (APRS-IS client protocol)",
|
|
"qCQ" => "qCQ (APRS-IS client query)",
|
|
"qCR" => "qCR (APRS-IS client router)",
|
|
"qCS" => "qCS (APRS-IS client server)",
|
|
"qCT" => "qCT (APRS-IS client terminal)",
|
|
"qCU" => "qCU (APRS-IS client user)",
|
|
"qCV" => "qCV (APRS-IS client vendor)",
|
|
"qCW" => "qCW (APRS-IS client web)",
|
|
"qCX" => "qCX (APRS-IS client experimental)",
|
|
"qCY" => "qCY (APRS-IS client Y2K)",
|
|
"qCZ" => "qCZ (APRS-IS client Zulu)"
|
|
}
|
|
|
|
@impl true
|
|
def mount(%{"callsign" => callsign}, _session, socket) do
|
|
normalized_callsign = Callsign.normalize(callsign)
|
|
|
|
# Subscribe to callsign-specific topic for live updates
|
|
_ =
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}")
|
|
end
|
|
|
|
packet = get_latest_packet(normalized_callsign)
|
|
packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet)
|
|
# Get locale from socket assigns (set by LocaleHook)
|
|
locale = Map.get(socket.assigns, :locale, "en")
|
|
neighbors = get_neighbors(packet, normalized_callsign, locale)
|
|
has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
|
|
other_ssids = Packets.get_other_ssids(normalized_callsign)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:callsign, normalized_callsign)
|
|
|> assign(:packet, packet)
|
|
|> assign(:neighbors, neighbors)
|
|
|> assign(:page_title, "APRS station #{normalized_callsign}")
|
|
|> assign(:has_weather_packets, has_weather_packets)
|
|
|> assign(:other_ssids, other_ssids)
|
|
|
|
send(self(), :load_aggregations)
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:postgres_packet, packet}, socket) do
|
|
# Since we're subscribed to callsign-specific topic, no need to filter
|
|
# The packet from PostgreSQL notify already contains all fields
|
|
process_packet_update(packet, socket)
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:load_aggregations, socket) do
|
|
locale = Map.get(socket.assigns, :locale, "en")
|
|
heard_by_stations = get_heard_by_stations(socket.assigns.callsign, locale)
|
|
stations_heard_by = get_stations_heard_by(socket.assigns.callsign, locale)
|
|
|
|
{:noreply, assign(socket, heard_by_stations: heard_by_stations, stations_heard_by: stations_heard_by)}
|
|
end
|
|
|
|
def handle_info(_message, socket), do: {:noreply, socket}
|
|
|
|
defp process_packet_update(incoming_packet, socket) do
|
|
Logger.debug(
|
|
"InfoLive received packet update for #{socket.assigns.callsign}: #{inspect(Map.get(incoming_packet, "raw_packet"))}"
|
|
)
|
|
|
|
# Get the new packet data
|
|
new_packet = get_latest_packet(socket.assigns.callsign)
|
|
new_packet = if new_packet, do: SharedPacketHandler.enrich_with_device_info(new_packet)
|
|
|
|
current_packet = socket.assigns.packet
|
|
|
|
# Check if this is a position update by comparing location and other key fields
|
|
position_changed = position_changed?(current_packet, new_packet)
|
|
|
|
if position_changed do
|
|
# Only update position-related data if position changed
|
|
# Get locale from socket assigns
|
|
locale = Map.get(socket.assigns, :locale, "en")
|
|
neighbors = get_neighbors(new_packet, socket.assigns.callsign, locale)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:packet, new_packet)
|
|
|> assign(:neighbors, neighbors)
|
|
|
|
{:noreply, socket}
|
|
else
|
|
# Just update the packet data (for timestamp, comment, etc.) without affecting neighbors
|
|
socket = assign(socket, :packet, new_packet)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@spec position_changed?(map() | nil, map() | nil) :: boolean()
|
|
defp position_changed?(nil, _new_packet), do: true
|
|
defp position_changed?(_current_packet, nil), do: false
|
|
|
|
defp position_changed?(current_packet, new_packet) do
|
|
coords_changed?({
|
|
EncodingUtils.to_float(current_packet.lat),
|
|
EncodingUtils.to_float(current_packet.lon),
|
|
EncodingUtils.to_float(new_packet.lat),
|
|
EncodingUtils.to_float(new_packet.lon)
|
|
})
|
|
end
|
|
|
|
@spec coords_changed?({float() | nil, float() | nil, float() | nil, float() | nil}) :: boolean()
|
|
defp coords_changed?({curr_lat, curr_lon, new_lat, new_lon})
|
|
when is_number(curr_lat) and is_number(curr_lon) and is_number(new_lat) and is_number(new_lon) do
|
|
threshold = Application.get_env(:aprsme, :position_tracking, [])[:change_threshold] || 0.001
|
|
abs(curr_lat - new_lat) > threshold or abs(curr_lon - new_lon) > threshold
|
|
end
|
|
|
|
defp coords_changed?(_), do: true
|
|
|
|
# Expose for testing
|
|
if Mix.env() == :test do
|
|
def position_changed_for_test(current, new), do: position_changed?(current, new)
|
|
end
|
|
|
|
defp get_latest_packet(callsign) do
|
|
# Get the most recent packet for this callsign, regardless of type
|
|
# This ensures we show the most recent activity, not just position packets
|
|
packet = Packets.get_latest_packet_for_callsign(callsign)
|
|
|
|
# If this packet doesn't have position data, try to get the latest position packet
|
|
if packet && (is_nil(packet.lat) || is_nil(packet.lon)) do
|
|
position_packet = get_latest_position_packet(callsign)
|
|
|
|
if position_packet do
|
|
# Merge position data from position packet into the latest packet
|
|
%{packet | lat: position_packet.lat, lon: position_packet.lon, location: position_packet.location}
|
|
else
|
|
packet
|
|
end
|
|
else
|
|
packet
|
|
end
|
|
end
|
|
|
|
defp get_latest_position_packet(callsign) do
|
|
# Get the most recent packet with valid position data
|
|
Repo.one(
|
|
from(p in Aprsme.Packet,
|
|
where: fragment("upper(?)", p.sender) == ^String.upcase(String.trim(callsign)),
|
|
where: not is_nil(p.lat) and not is_nil(p.lon),
|
|
order_by: [desc: p.received_at],
|
|
limit: 1,
|
|
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
|
|
)
|
|
)
|
|
end
|
|
|
|
defp get_neighbors(nil, _callsign, _locale), do: []
|
|
|
|
defp get_neighbors(packet, callsign, locale) do
|
|
lat = packet.lat
|
|
lon = packet.lon
|
|
|
|
if is_nil(lat) or is_nil(lon) do
|
|
[]
|
|
else
|
|
# Convert Decimal to float if needed
|
|
lat_float = to_float(lat)
|
|
lon_float = to_float(lon)
|
|
|
|
# Use the spatial query to get the closest stations
|
|
# The query already returns them sorted by distance
|
|
nearby_packets =
|
|
Packets.get_nearby_stations(
|
|
lat_float,
|
|
lon_float,
|
|
callsign,
|
|
%{limit: @neighbor_limit}
|
|
)
|
|
|
|
# Calculate distance and course for each neighbor
|
|
# The packets are already sorted by distance from the database
|
|
Enum.map(nearby_packets, fn p ->
|
|
dist = haversine(lat, lon, p.lat, p.lon)
|
|
course = calculate_course(lat, lon, p.lat, p.lon)
|
|
|
|
%{
|
|
callsign: p.sender,
|
|
distance: format_distance(dist, locale),
|
|
distance_km: dist,
|
|
course: course,
|
|
last_heard: format_timestamp_for_display(p),
|
|
packet: p
|
|
}
|
|
end)
|
|
end
|
|
end
|
|
|
|
@typep timestamp_display :: %{
|
|
time_ago: String.t(),
|
|
formatted: String.t(),
|
|
timestamp: String.t() | nil
|
|
}
|
|
|
|
@spec haversine(any(), any(), any(), any()) :: float()
|
|
def haversine(lat1, lon1, lat2, lon2) do
|
|
# Returns distance in km. Decimals are converted to float; nil/invalid inputs become 0.0.
|
|
GeoUtils.haversine_distance(to_float(lat1), to_float(lon1), to_float(lat2), to_float(lon2)) / 1000
|
|
end
|
|
|
|
@spec to_float(any()) :: float()
|
|
defp to_float(value), do: EncodingUtils.to_float(value) || 0.0
|
|
|
|
@spec format_timestamp_for_display(map()) :: timestamp_display()
|
|
defp format_timestamp_for_display(packet) do
|
|
packet
|
|
|> get_received_at()
|
|
|> AprsmeWeb.TimeHelpers.to_datetime()
|
|
|> build_timestamp_display()
|
|
end
|
|
|
|
@spec build_timestamp_display(DateTime.t() | nil) :: timestamp_display()
|
|
defp build_timestamp_display(nil), do: %{time_ago: gettext("Unknown"), formatted: "", timestamp: nil}
|
|
|
|
defp build_timestamp_display(%DateTime{} = dt) do
|
|
%{
|
|
time_ago: AprsmeWeb.TimeHelpers.time_ago_in_words(dt),
|
|
formatted: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC"),
|
|
timestamp: DateTime.to_iso8601(dt)
|
|
}
|
|
end
|
|
|
|
@spec get_received_at(map()) :: DateTime.t() | NaiveDateTime.t() | String.t() | integer() | nil
|
|
defp get_received_at(%{received_at: value}), do: value
|
|
defp get_received_at(%{"received_at" => value}), do: value
|
|
defp get_received_at(_), do: nil
|
|
|
|
# English locale uses imperial (ft/mi), everything else uses metric (m/km).
|
|
def format_distance(km, locale \\ "en")
|
|
|
|
def format_distance(km, "en") do
|
|
miles = Aprsme.Convert.kph_to_mph(km)
|
|
format_imperial_distance(miles)
|
|
end
|
|
|
|
def format_distance(km, _locale), do: format_metric_distance(km)
|
|
|
|
defp format_imperial_distance(miles) when miles < 1.0, do: "#{Float.round(miles * 5280, 0)} ft"
|
|
|
|
defp format_imperial_distance(miles), do: "#{Float.round(miles, 2)} mi"
|
|
|
|
defp format_metric_distance(km) when km < 1.0, do: "#{Float.round(km * 1000, 0)} m"
|
|
defp format_metric_distance(km), do: "#{Float.round(km, 2)} km"
|
|
|
|
def calculate_course(lat1, lon1, lat2, lon2) do
|
|
# Convert Decimal to float if needed.
|
|
lat1 = to_float(lat1)
|
|
lon1 = to_float(lon1)
|
|
lat2 = to_float(lat2)
|
|
lon2 = to_float(lon2)
|
|
|
|
dlon = :math.pi() / 180 * (lon2 - lon1)
|
|
lat1_rad = :math.pi() / 180 * lat1
|
|
lat2_rad = :math.pi() / 180 * lat2
|
|
|
|
y = :math.sin(dlon) * :math.cos(lat2_rad)
|
|
x = :math.cos(lat1_rad) * :math.sin(lat2_rad) - :math.sin(lat1_rad) * :math.cos(lat2_rad) * :math.cos(dlon)
|
|
|
|
normalize_bearing(:math.atan2(y, x) * 180 / :math.pi())
|
|
end
|
|
|
|
# Convert atan2 output from ±180 to 0-360.
|
|
defp normalize_bearing(b) when b < 0, do: b + 360
|
|
defp normalize_bearing(b), do: b
|
|
|
|
defp get_heard_by_stations(callsign, locale) do
|
|
# Get packets from the last month where this callsign was heard on RF
|
|
one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day)
|
|
|
|
# Query to find stations that heard this callsign directly
|
|
# In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly
|
|
query = """
|
|
WITH digipeater_location AS (
|
|
SELECT DISTINCT ON (sender)
|
|
sender as digipeater,
|
|
location::geography as dig_loc
|
|
FROM packets
|
|
WHERE sender IN (
|
|
SELECT DISTINCT
|
|
substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
|
|
FROM packets
|
|
WHERE sender = $1
|
|
AND received_at >= $2
|
|
AND path IS NOT NULL
|
|
AND path != ''
|
|
AND path !~ '^TCPIP'
|
|
AND path !~ ',TCPIP'
|
|
AND path ~ '[A-Z0-9]+-?[0-9]*\\*'
|
|
)
|
|
AND location IS NOT NULL
|
|
ORDER BY sender, received_at DESC
|
|
),
|
|
parsed_paths AS (
|
|
SELECT
|
|
id,
|
|
sender,
|
|
path,
|
|
received_at,
|
|
lat,
|
|
lon,
|
|
location,
|
|
-- Extract the first digipeater from the path
|
|
CASE
|
|
WHEN path ~ '[A-Z0-9]+-?[0-9]*\\*' THEN
|
|
substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
|
|
ELSE NULL
|
|
END as first_digipeater
|
|
FROM packets
|
|
WHERE sender = $1
|
|
AND received_at >= $2
|
|
AND path IS NOT NULL
|
|
AND path != ''
|
|
AND path !~ '^TCPIP'
|
|
AND path !~ ',TCPIP'
|
|
),
|
|
longest_paths AS (
|
|
SELECT DISTINCT ON (pp.first_digipeater)
|
|
pp.first_digipeater,
|
|
pp.id as longest_path_packet_id
|
|
FROM parsed_paths pp
|
|
WHERE pp.first_digipeater IS NOT NULL
|
|
AND pp.lat IS NOT NULL
|
|
AND pp.lon IS NOT NULL
|
|
ORDER BY pp.first_digipeater,
|
|
ST_Distance(
|
|
pp.location::geography,
|
|
COALESCE(
|
|
(SELECT dl.dig_loc FROM digipeater_location dl WHERE dl.digipeater = pp.first_digipeater),
|
|
pp.location::geography
|
|
)
|
|
) DESC NULLS LAST
|
|
),
|
|
digipeater_stats AS (
|
|
SELECT
|
|
pp.first_digipeater as digipeater,
|
|
MIN(pp.received_at) as first_heard,
|
|
MAX(pp.received_at) as last_heard,
|
|
COUNT(*) as packet_count,
|
|
MAX(lp.longest_path_packet_id) as longest_path_packet_id
|
|
FROM parsed_paths pp
|
|
LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater
|
|
WHERE pp.first_digipeater IS NOT NULL
|
|
GROUP BY pp.first_digipeater
|
|
)
|
|
SELECT
|
|
ds.digipeater,
|
|
ds.first_heard,
|
|
ds.last_heard,
|
|
ds.packet_count,
|
|
p.received_at as longest_path_time,
|
|
CASE
|
|
WHEN p.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN
|
|
ST_Distance(p.location::geography, dl.dig_loc) / 1000.0
|
|
ELSE NULL
|
|
END as longest_distance_km
|
|
FROM digipeater_stats ds
|
|
LEFT JOIN packets p ON p.id = ds.longest_path_packet_id
|
|
LEFT JOIN digipeater_location dl ON dl.digipeater = ds.digipeater
|
|
ORDER BY ds.last_heard DESC
|
|
LIMIT 50
|
|
"""
|
|
|
|
case Repo.query(query, [callsign, one_month_ago]) do
|
|
{:ok, result} ->
|
|
result.rows
|
|
|> Enum.map(fn row -> map_digipeater_row(row, locale) end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
{:error, error} ->
|
|
Logger.error("Error in get_heard_by_stations: #{inspect(error)}")
|
|
[]
|
|
end
|
|
end
|
|
|
|
defp get_stations_heard_by(callsign, locale) do
|
|
# Get packets from the last month where this callsign heard other stations on RF
|
|
one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day)
|
|
|
|
# Optimized query to find stations that were heard directly by this callsign
|
|
# Eliminates correlated subqueries and reduces redundant operations
|
|
query = """
|
|
WITH digipeater_location AS (
|
|
-- Get the most recent location for the digipeater (cached once)
|
|
SELECT location::geography as dig_loc
|
|
FROM packets
|
|
WHERE sender = $1
|
|
AND location IS NOT NULL
|
|
ORDER BY received_at DESC
|
|
LIMIT 1
|
|
),
|
|
parsed_paths AS (
|
|
SELECT
|
|
id,
|
|
sender,
|
|
path,
|
|
received_at,
|
|
lat,
|
|
lon,
|
|
location,
|
|
-- Extract the first digipeater from the path more efficiently
|
|
CASE
|
|
WHEN path ~ ($1 || '\\*') THEN
|
|
substring(path from ($1 || '\\*'))
|
|
ELSE NULL
|
|
END as first_digipeater
|
|
FROM packets
|
|
WHERE path ~ ($1 || '\\*')
|
|
AND received_at >= $2
|
|
AND path IS NOT NULL
|
|
AND path != ''
|
|
AND path !~ '^TCPIP'
|
|
AND path !~ ',TCPIP'
|
|
AND lat IS NOT NULL
|
|
AND lon IS NOT NULL
|
|
),
|
|
station_stats AS (
|
|
SELECT
|
|
sender as station,
|
|
MIN(received_at) as first_heard,
|
|
MAX(received_at) as last_heard,
|
|
COUNT(*) as packet_count
|
|
FROM parsed_paths
|
|
WHERE first_digipeater = $1
|
|
GROUP BY sender
|
|
),
|
|
longest_paths AS (
|
|
-- Find the longest path packet for each station in a single pass
|
|
SELECT DISTINCT ON (pp.sender)
|
|
pp.sender,
|
|
pp.id as longest_path_packet_id,
|
|
pp.received_at as longest_path_time,
|
|
CASE
|
|
WHEN pp.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN
|
|
ST_Distance(pp.location::geography, dl.dig_loc) / 1000.0
|
|
ELSE NULL
|
|
END as longest_distance_km
|
|
FROM parsed_paths pp
|
|
CROSS JOIN digipeater_location dl
|
|
WHERE pp.first_digipeater = $1
|
|
ORDER BY pp.sender,
|
|
ST_Distance(pp.location::geography, dl.dig_loc) DESC NULLS LAST,
|
|
pp.received_at DESC
|
|
)
|
|
SELECT
|
|
ss.station,
|
|
ss.first_heard,
|
|
ss.last_heard,
|
|
ss.packet_count,
|
|
lp.longest_path_time,
|
|
lp.longest_distance_km
|
|
FROM station_stats ss
|
|
LEFT JOIN longest_paths lp ON lp.sender = ss.station
|
|
ORDER BY ss.last_heard DESC
|
|
LIMIT 50
|
|
"""
|
|
|
|
case Repo.query(query, [callsign, one_month_ago]) do
|
|
{:ok, result} ->
|
|
result.rows
|
|
|> Enum.map(fn row -> map_station_heard_row(row, locale) end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
{:error, error} ->
|
|
Logger.error("Error in get_stations_heard_by: #{inspect(error)}")
|
|
[]
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Renders an APRS symbol as HTML for overlay symbols that need proper overlay character display.
|
|
"""
|
|
def render_symbol_html(packet, size \\ 32) do
|
|
if packet do
|
|
{symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet)
|
|
|
|
if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do
|
|
sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code)
|
|
overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id)
|
|
|
|
style =
|
|
"position: relative; width: #{size}px; height: #{size}px; background-image: url(#{overlay_sprite_info.sprite_file}), url(#{sprite_info.sprite_file}); background-position: #{overlay_sprite_info.background_position}, #{sprite_info.background_position}; background-size: #{overlay_sprite_info.background_size}, #{sprite_info.background_size}; background-repeat: no-repeat, no-repeat; image-rendering: pixelated; display: inline-block; vertical-align: middle; margin-bottom: -6px;"
|
|
|
|
escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
|
|
|
raw("<div style=\"#{escaped_style}\"></div>")
|
|
else
|
|
style = AprsSymbol.render_style(symbol_table_id, symbol_code, size)
|
|
escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
|
|
|
raw("<div style=\"#{escaped_style}\"></div>")
|
|
end
|
|
else
|
|
raw("")
|
|
end
|
|
end
|
|
|
|
defp decode_aprs_path(path) when is_binary(path) and path != "" do
|
|
path_elements = String.split(path, ",")
|
|
|
|
decoded_elements = Enum.map(path_elements, &decode_path_element/1)
|
|
|
|
# Filter out nil results and join with explanations
|
|
decoded_elements
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.join(" → ")
|
|
end
|
|
|
|
defp decode_aprs_path(_), do: nil
|
|
|
|
@doc """
|
|
Parses the APRS path and creates linked callsigns where appropriate.
|
|
Returns a list of tuples: {:text, string} or {:link, callsign, display_text}
|
|
"""
|
|
def parse_path_with_links(path) when is_binary(path) and path != "" do
|
|
path
|
|
|> String.split(",")
|
|
|> Enum.map(&parse_path_element_with_link/1)
|
|
end
|
|
|
|
def parse_path_with_links(_), do: []
|
|
|
|
defp parse_path_element_with_link(element) do
|
|
element = String.trim(element)
|
|
classify_path_element(linkable_callsign?(element), element)
|
|
end
|
|
|
|
# Only real callsigns (not digipeater aliases or q-constructs) become links.
|
|
defp linkable_callsign?(element) do
|
|
Regex.match?(~r/^[A-Z0-9]{1,6}(-\d{1,2})?(\*)?$/, element) and
|
|
not path_alias?(element)
|
|
end
|
|
|
|
defp path_alias?(element) do
|
|
String.starts_with?(element, "WIDE") or
|
|
String.starts_with?(element, "TRACE") or
|
|
String.starts_with?(element, "RELAY") or
|
|
String.starts_with?(element, "TCPIP") or
|
|
String.starts_with?(element, "q")
|
|
end
|
|
|
|
# Strip the trailing `*` from the canonical callsign while preserving it
|
|
# in the display string so the UI still shows "was-heard-via" markers.
|
|
defp classify_path_element(true, element) do
|
|
callsign = String.replace(element, "*", "")
|
|
{:link, callsign, display_for_element(String.ends_with?(element, "*"), callsign)}
|
|
end
|
|
|
|
defp classify_path_element(false, element), do: {:text, element, element}
|
|
|
|
defp display_for_element(true, callsign), do: "#{callsign}*"
|
|
defp display_for_element(false, callsign), do: callsign
|
|
|
|
defp decode_path_element(element) do
|
|
element = String.trim(element)
|
|
|
|
cond do
|
|
# Check Q-constructs map first (most common)
|
|
Map.has_key?(@q_constructs, element) ->
|
|
@q_constructs[element]
|
|
|
|
# WIDE digipeaters
|
|
String.starts_with?(element, "WIDE") ->
|
|
decode_wide_element(element)
|
|
|
|
# TRACE digipeaters
|
|
String.starts_with?(element, "TRACE") ->
|
|
decode_trace_element(element)
|
|
|
|
# RELAY digipeaters
|
|
String.starts_with?(element, "RELAY") ->
|
|
decode_relay_element(element)
|
|
|
|
# TCPIP digipeaters
|
|
String.starts_with?(element, "TCPIP") ->
|
|
decode_tcpip_element(element)
|
|
|
|
# Generic callsign with SSID (likely a digipeater)
|
|
Regex.match?(~r/^[A-Z0-9]+-\d+$/, element) ->
|
|
gettext("%{element} (Digipeater)", element: element)
|
|
|
|
# Generic callsign without SSID
|
|
Regex.match?(~r/^[A-Z0-9]+$/, element) ->
|
|
gettext("%{element} (Station)", element: element)
|
|
|
|
# Default case
|
|
true ->
|
|
gettext("%{element} (Unknown)", element: element)
|
|
end
|
|
end
|
|
|
|
defp decode_wide_element("WIDE1-1"), do: gettext("WIDE1-1 (Wide area digipeater, 1 hop)")
|
|
defp decode_wide_element("WIDE2-1"), do: gettext("WIDE2-1 (Wide area digipeater, 2 hops)")
|
|
defp decode_wide_element("WIDE3-1"), do: gettext("WIDE3-1 (Wide area digipeater, 3 hops)")
|
|
defp decode_wide_element("WIDE4-1"), do: gettext("WIDE4-1 (Wide area digipeater, 4 hops)")
|
|
defp decode_wide_element("WIDE5-1"), do: gettext("WIDE5-1 (Wide area digipeater, 5 hops)")
|
|
defp decode_wide_element("WIDE6-1"), do: gettext("WIDE6-1 (Wide area digipeater, 6 hops)")
|
|
defp decode_wide_element("WIDE7-1"), do: gettext("WIDE7-1 (WIDE area digipeater, 7 hops)")
|
|
defp decode_wide_element("WIDE1-2"), do: gettext("WIDE1-2 (Wide area digipeater, 1 hop, 2nd attempt)")
|
|
defp decode_wide_element("WIDE2-2"), do: gettext("WIDE2-2 (Wide area digipeater, 2 hops, 2nd attempt)")
|
|
defp decode_wide_element(element), do: gettext("WIDE digipeater (%{element})", element: element)
|
|
|
|
defp decode_trace_element("TRACE1-1"), do: gettext("TRACE1-1 (Trace digipeater, 1 hop)")
|
|
defp decode_trace_element("TRACE2-1"), do: gettext("TRACE2-1 (Trace digipeater, 2 hops)")
|
|
defp decode_trace_element("TRACE3-1"), do: gettext("TRACE3-1 (Trace digipeater, 3 hops)")
|
|
defp decode_trace_element("TRACE4-1"), do: gettext("TRACE4-1 (Trace digipeater, 4 hops)")
|
|
defp decode_trace_element("TRACE5-1"), do: gettext("TRACE5-1 (Trace digipeater, 5 hops)")
|
|
defp decode_trace_element("TRACE6-1"), do: gettext("TRACE6-1 (Trace digipeater, 6 hops)")
|
|
defp decode_trace_element("TRACE7-1"), do: gettext("TRACE7-1 (Trace digipeater, 7 hops)")
|
|
defp decode_trace_element(element), do: gettext("TRACE digipeater (%{element})", element: element)
|
|
|
|
defp decode_relay_element("RELAY"), do: gettext("RELAY (Relay digipeater)")
|
|
defp decode_relay_element("RELAY-1"), do: gettext("RELAY-1 (Relay digipeater, 1 hop)")
|
|
defp decode_relay_element("RELAY-2"), do: gettext("RELAY-2 (Relay digipeater, 2 hops)")
|
|
defp decode_relay_element(element), do: gettext("RELAY digipeater (%{element})", element: element)
|
|
|
|
defp decode_tcpip_element("TCPIP"), do: gettext("TCPIP (Internet gateway)")
|
|
defp decode_tcpip_element("TCPIP*"), do: gettext("TCPIP* (Internet gateway, no forward)")
|
|
defp decode_tcpip_element(element), do: gettext("TCPIP gateway (%{element})", element: element)
|
|
|
|
defp map_digipeater_row(row, locale) do
|
|
case row do
|
|
[digipeater, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] ->
|
|
%{
|
|
digipeater: digipeater || "",
|
|
first_heard: first_heard,
|
|
last_heard: last_heard,
|
|
packet_count: packet_count || 0,
|
|
longest_distance_km: longest_distance_km,
|
|
longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)),
|
|
longest_path_time: longest_path_time
|
|
}
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp map_station_heard_row(row, locale) do
|
|
case row do
|
|
[station, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] ->
|
|
%{
|
|
station: station || "",
|
|
first_heard: first_heard,
|
|
last_heard: last_heard,
|
|
packet_count: packet_count || 0,
|
|
longest_distance_km: longest_distance_km,
|
|
longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)),
|
|
longest_path_time: longest_path_time
|
|
}
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
end
|