defmodule Microwaveprop.Format do @moduledoc """ Shared human-facing formatters. Miles are the primary distance unit across the site; km appears parenthetically for readers working in metric. """ # ── Propagation score tier definitions ── @tier_thresholds [ {80, "EXCELLENT", "badge badge-sm badge-success", "#059669"}, {65, "GOOD", "badge badge-sm badge-info", "#0d9488"}, {50, "MARGINAL", "badge badge-sm badge-warning", "#ca8a04"}, {33, "POOR", "badge badge-sm badge-error", "#ea580c"} ] @fallback_tier {"NEGLIGIBLE", "badge badge-sm badge-ghost", "#dc2626"} @doc """ Human-readable label for a propagation composite score (0-100). """ @spec propagation_tier_label(number()) :: String.t() def propagation_tier_label(score) do Enum.find_value(@tier_thresholds, elem(@fallback_tier, 0), fn {thresh, label, _badge, _color} -> score >= thresh && label end) end @doc """ CSS badge classes for a propagation composite score (0-100). Includes "badge badge-sm" prefix so callers can use the result as a bare `class` value. """ @spec propagation_tier_badge_class(number()) :: String.t() def propagation_tier_badge_class(score) do Enum.find_value(@tier_thresholds, elem(@fallback_tier, 1), fn {thresh, _label, badge, _color} -> score >= thresh && badge end) end @doc """ Hex color (no # prefix) for a propagation composite score (0-100). """ @spec propagation_tier_color(number()) :: String.t() def propagation_tier_color(score) do Enum.find_value(@tier_thresholds, elem(@fallback_tier, 2), fn {thresh, _label, _badge, color} -> score >= thresh && color end) end @doc """ Tailwind badge color class for a terrain analysis verdict. Returns only the color portion ("badge-success", "badge-warning", etc.) — callers append "badge" / "badge-sm" as needed. """ @spec terrain_verdict_class(String.t()) :: String.t() def terrain_verdict_class("CLEAR"), do: "badge-success" def terrain_verdict_class("FRESNEL_MINOR"), do: "badge-warning" def terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning" def terrain_verdict_class("BLOCKED"), do: "badge-error" def terrain_verdict_class(_), do: "badge-ghost" @doc """ Format a distance in km as "X mi (Y km)". Under 10 miles the output carries one decimal so near-LOS paths don't round to zero; longer distances round to whole numbers. """ @spec distance_km(number() | Decimal.t() | nil) :: String.t() def distance_km(nil), do: "—" def distance_km(%Decimal{} = d), do: d |> Decimal.to_float() |> distance_km() def distance_km(km) when is_number(km) do mi = km / 1.609344 {mi_str, km_str} = if mi < 10.0, do: {one_decimal(mi), one_decimal(km)}, else: {Integer.to_string(round(mi)), Integer.to_string(round(km))} "#{mi_str} mi (#{km_str} km)" end defp one_decimal(n), do: :erlang.float_to_binary(n * 1.0, decimals: 1) @doc """ Thousand-grouped integer for UI counters (row counts, job totals, etc). Floats round to nearest integer first; non-numerics are passed through to `to_string/1` so a `nil` or `:unknown` renders without crashing. """ @spec number(integer() | float() | any()) :: String.t() def number(n) when is_integer(n) do n |> Integer.to_string() |> String.graphemes() |> Enum.reverse() |> Enum.chunk_every(3) |> Enum.map_join(",", &Enum.join/1) |> String.reverse() |> String.replace(~r/^,/, "") end def number(n) when is_float(n), do: number(round(n)) def number(n), do: to_string(n) @doc """ Binary-prefix byte count (1024-based) with one decimal. Intended for admin / status surfaces where "1.4 MB" is more useful than "1468006". """ @spec bytes(non_neg_integer()) :: String.t() def bytes(b) when b >= 1_073_741_824, do: "#{Float.round(b / 1_073_741_824, 1)} GB" def bytes(b) when b >= 1_048_576, do: "#{Float.round(b / 1_048_576, 1)} MB" def bytes(b) when b >= 1024, do: "#{Float.round(b / 1024, 1)} KB" def bytes(b), do: "#{b} B" end