aprs.me/lib/aprsme_web/aprs_symbol.ex
Graham McIntire 80983e6223
fix: repair all 60 failing tests after refactor and dep updates
- IS GenServer: add missing :failure_started_at to test build_state/1 map
- IS GenServer: fix stale status server assertion and reconnection test
- IS GenServer: set Logger level to :debug for dispatch parse-error tests
- PacketReplay: remove conflicting Registry start_supervised! from setup
- PacketReplay: update assertion from {:continue_replay} to :start_replay
- Doctests: fix float precision, stale sprite coords, quoting escapes
- API controllers: use string keys for JSON error/postion details
- PacketUtils: fix operator precedence (not is_nil(result).field)
- ThemeManager: update expected dark theme text color
- StatusLive: remove/update stale :loading assign assertions
- Movement: remove {:ok, _v} wrapper from render_hook/3 (returns HTML)
- AprsIsMock: update packet_stats assertion for populated default shape
2026-07-01 09:41:10 -05:00

320 lines
11 KiB
Elixir

defmodule AprsmeWeb.AprsSymbol do
@moduledoc """
Shared library for APRS symbol handling and rendering.
This module provides centralized functions for:
- Symbol table and code normalization
- Sprite file mapping
- Symbol positioning calculations
- HTML generation for symbols
All APRS symbol logic should use this module to ensure consistency
across the application.
"""
@doc """
Gets sprite information for a given symbol table and code.
Returns a map with sprite_file, background_position, and background_size.
## Examples
iex> AprsmeWeb.AprsSymbol.get_sprite_info("/", "_")
%{
sprite_file: "/aprs-symbols/aprs-symbols-128-0@2x.png",
background_position: "-448.0px -96.0px",
background_size: "512px 192px"
}
"""
@spec get_sprite_info(String.t() | nil, String.t() | nil) :: sprite_info()
def get_sprite_info(symbol_table, symbol_code) do
compute_sprite_info(overlay_symbol?(symbol_table), symbol_table, symbol_code)
end
@typedoc "Sprite-sheet positioning information for a single symbol."
@type sprite_info :: %{
sprite_file: String.t(),
background_position: String.t(),
background_size: String.t()
}
@spec overlay_symbol?(any()) :: boolean()
defp overlay_symbol?(table) when is_binary(table), do: String.match?(table, ~r/^[A-Z0-9]$/)
defp overlay_symbol?(_), do: false
@spec compute_sprite_info(boolean(), String.t() | nil, String.t() | nil) :: sprite_info()
defp compute_sprite_info(true, _symbol_table, symbol_code) do
get_overlay_base_symbol_info(symbol_code)
end
defp compute_sprite_info(false, symbol_table, symbol_code) do
symbol_table = normalize_symbol_table(symbol_table)
symbol_code = normalize_symbol_code(symbol_code)
table_id = get_table_id(symbol_table)
build_sprite_info(table_id, symbol_code)
end
# Builds the sprite_info map for a given table and symbol char.
# The 128x128 sprite sheet is a 16-column grid; positions are scaled to 32px display.
@spec build_sprite_info(String.t(), String.t() | nil) :: sprite_info()
defp build_sprite_info(table_id, symbol_char) do
safe_index = symbol_char |> get_symbol_code_ord() |> Kernel.-(33) |> max(0) |> min(93)
x = -rem(safe_index, 16) * 128
y = -div(safe_index, 16) * 128
%{
sprite_file: "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png",
background_position: "#{x / 4}px #{y / 4}px",
background_size: "512px 192px"
}
end
@doc """
Gets sprite information for overlay symbols (A-Z, 0-9).
These symbols display the base symbol from the overlay table.
"""
@spec get_overlay_base_symbol_info(String.t()) :: sprite_info()
def get_overlay_base_symbol_info(base_symbol_code) do
build_sprite_info(get_overlay_base_table_id(base_symbol_code), base_symbol_code)
end
@doc """
Determines which sprite table to use for overlay base symbols.
Some symbols are in the alternate table (1), others in overlay table (2).
"""
@spec get_overlay_base_table_id(String.t()) :: String.t()
def get_overlay_base_table_id(_base_symbol_code) do
# All overlay symbols are in the alternate table (1) per APRS specification
# This includes digipeaters, diamonds, squares, arrows, etc.
"1"
end
@doc """
Gets sprite information for overlay characters (A-Z, 0-9).
These are rendered from the overlay table.
"""
@spec get_overlay_character_sprite_info(String.t()) :: sprite_info()
def get_overlay_character_sprite_info(overlay_char), do: build_sprite_info("2", overlay_char)
@doc """
Normalizes a symbol table identifier.
## Examples
iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("/")
"/"
iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("A")
"]"
iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("invalid")
"/"
"""
@spec normalize_symbol_table(String.t() | nil) :: String.t()
def normalize_symbol_table(table) when table in ["/", "\\", "]"], do: table
def normalize_symbol_table(table) when is_binary(table) do
# A single alphanumeric character is an overlay — map to the overlay table.
if String.match?(table, ~r/^[A-Z0-9]$/) do
"]"
else
"/"
end
end
def normalize_symbol_table(_), do: "/"
@doc """
Normalizes a symbol code.
## Examples
iex> AprsmeWeb.AprsSymbol.normalize_symbol_code("_")
"_"
iex> AprsmeWeb.AprsSymbol.normalize_symbol_code(nil)
">"
iex> AprsmeWeb.AprsSymbol.normalize_symbol_code("")
">"
"""
@spec normalize_symbol_code(String.t() | nil) :: String.t()
def normalize_symbol_code(nil), do: ">"
def normalize_symbol_code(""), do: ">"
def normalize_symbol_code(symbol_code), do: symbol_code
@doc """
Maps a symbol table to its sprite file ID.
## Examples
iex> AprsmeWeb.AprsSymbol.get_table_id("/")
"0"
iex> AprsmeWeb.AprsSymbol.get_table_id("\\\\")
"1"
iex> AprsmeWeb.AprsSymbol.get_table_id("]")
"2"
"""
@spec get_table_id(String.t()) :: String.t()
def get_table_id("/"), do: "0"
def get_table_id("\\"), do: "1"
def get_table_id("]"), do: "2"
def get_table_id(_), do: "0"
@doc """
Renders an APRS symbol as HTML for use in Leaflet markers.
Returns HTML string that can be used as marker content.
## Examples
iex> AprsmeWeb.AprsSymbol.render_marker_html("/", "_", "W1AW") |> String.contains?("W1AW")
true
"""
@spec render_marker_html(String.t() | nil, String.t() | nil, String.t() | nil, integer()) :: String.t()
def render_marker_html(symbol_table, symbol_code, callsign \\ nil, size \\ 32) do
# For symbols without callsigns, use Cachex for better caching
if is_nil(callsign) do
cache_key = "symbol_html:#{symbol_table}:#{symbol_code}:#{size}"
case Aprsme.Cache.get(:symbol_cache, cache_key) do
{:ok, html} when not is_nil(html) ->
html
_ ->
html = generate_marker_html(symbol_table, symbol_code, nil, size)
# Cache for 1 hour since symbols don't change
Aprsme.Cache.put(:symbol_cache, cache_key, html, ttl: Aprsme.Cache.to_timeout(hour: 1))
html
end
else
# For symbols with callsigns, generate directly (callsigns are dynamic)
generate_marker_html(symbol_table, symbol_code, callsign, size)
end
end
@spec generate_marker_html(String.t() | nil, String.t() | nil, String.t() | nil, integer()) :: String.t()
defp generate_marker_html(symbol_table, symbol_code, callsign, size) do
sprite_info = get_sprite_info(symbol_table, symbol_code)
# Check if this is an overlay symbol
is_overlay = symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/)
symbol_html =
if is_overlay do
# For overlay symbols, we need both the base symbol background and the overlay character
overlay_sprite_info = get_overlay_character_sprite_info(symbol_table)
"""
<div 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;
" title="#{symbol_table}#{symbol_code}">
</div>
"""
else
"""
<div style="
width: #{size}px;
height: #{size}px;
background-image: url(#{sprite_info.sprite_file});
background-position: #{sprite_info.background_position};
background-size: #{sprite_info.background_size};
background-repeat: no-repeat;
image-rendering: pixelated;
" title="#{symbol_table}#{symbol_code}"></div>
"""
end
if callsign do
"""
<div style="position: relative; width: #{size}px; height: #{size}px; display: flex; align-items: center;">
#{symbol_html}
<div style="
position: absolute;
left: #{size + 4}px;
top: 50%;
transform: translateY(-50%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
font-weight: 600;
color: #1e40af;
background-color: rgba(255, 255, 255, 0.9);
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
border: 1px solid rgba(30, 64, 175, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
pointer-events: auto;
z-index: 1000;
">#{callsign}</div>
</div>
"""
else
symbol_html
end
end
@doc """
Renders an APRS symbol as a style string for use in templates.
Returns a CSS style string that can be used directly in HTML.
## Examples
iex> AprsmeWeb.AprsSymbol.render_style("/", "_", 32)
"width: 32px; height: 32px; background-image: url(/aprs-symbols/aprs-symbols-128-0@2x.png); background-position: -448.0px -96.0px; background-size: 512px 192px; background-repeat: no-repeat; image-rendering: pixelated; opacity: 1.0; display: inline-block; vertical-align: middle; margin-bottom: -6px;"
"""
@spec render_style(String.t() | nil, String.t() | nil, integer()) :: String.t()
def render_style(symbol_table, symbol_code, size \\ 32) do
sprite_info = get_sprite_info(symbol_table, symbol_code)
"width: #{size}px; height: #{size}px; background-image: url(#{sprite_info.sprite_file}); background-position: #{sprite_info.background_position}; background-size: #{sprite_info.background_size}; background-repeat: no-repeat; image-rendering: pixelated; opacity: 1.0; display: inline-block; vertical-align: middle; margin-bottom: -6px;"
end
@doc """
Extracts symbol information from a packet with fallbacks.
## Examples
iex> AprsmeWeb.AprsSymbol.extract_from_packet(%{symbol_table_id: "/", symbol_code: "_"})
{"/", "_"}
iex> AprsmeWeb.AprsSymbol.extract_from_packet(%{})
{"/", ">"}
"""
@spec extract_from_packet(map()) :: {String.t(), String.t()}
def extract_from_packet(packet) do
symbol_table_id = get_packet_field(packet, :symbol_table_id, "/")
symbol_code = get_packet_field(packet, :symbol_code, ">")
{symbol_table_id, symbol_code}
end
# Helper function to safely extract a value from a packet or data_extended map
@spec get_packet_field(map(), atom(), String.t()) :: String.t()
defp get_packet_field(packet, field, default) do
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{}
Map.get(packet, field) ||
Map.get(packet, to_string(field)) ||
Map.get(data_extended, field) ||
Map.get(data_extended, to_string(field)) ||
default
end
@spec get_symbol_code_ord(String.t() | nil) :: non_neg_integer()
defp get_symbol_code_ord(symbol_code) when is_binary(symbol_code) do
case String.to_charlist(symbol_code) do
[c | _] when is_integer(c) -> c
_ -> 63
end
end
defp get_symbol_code_ord(_), do: 63
end