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: "-352px -32px", 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") "
..." """ @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) """
""" else """
""" end if callsign do """
#{symbol_html}
#{callsign}
""" 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); ..." """ @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