defmodule Raxol.Sensor.HUD do @moduledoc """ Pure functional HUD rendering for sensor data. All functions take a region `{x, y, w, h}`, data, and options, and return a list of `{x, y, char, fg, bg, attrs}` cell tuples. """ @type cell :: {non_neg_integer(), non_neg_integer(), String.t(), atom(), atom(), map()} @type region :: {non_neg_integer(), non_neg_integer(), pos_integer(), pos_integer()} @spark_chars ~w(▁ ▂ ▃ ▄ ▅ ▆ ▇ █) @doc """ Renders a horizontal gauge bar. Options: - `label` -- prefix label (default: "") - `min` -- minimum value (default: 0.0) - `max` -- maximum value (default: 100.0) - `thresholds` -- `{warn, crit}` percentages (default: `{0.6, 0.85}`) """ @spec render_gauge(region(), number(), keyword()) :: [cell()] def render_gauge({x, y, w, _h}, value, opts \\ []) do label = Keyword.get(opts, :label, "") min_val = Keyword.get(opts, :min, 0.0) max_val = Keyword.get(opts, :max, 100.0) {warn_pct, crit_pct} = Keyword.get(opts, :thresholds, {0.6, 0.85}) range = max_val - min_val raw_pct = if range > 0, do: (value - min_val) / range, else: 0.0 pct = clamp(raw_pct, 0.0, 1.0) prefix = if label != "", do: "#{label} ", else: "" suffix = " #{round(pct * 100)}%" bar_width = max(w - String.length(prefix) - String.length(suffix) - 2, 1) filled = round(pct * bar_width) empty = bar_width - filled fg = gauge_color(pct, warn_pct, crit_pct) line = prefix <> "[" <> String.duplicate("█", filled) <> String.duplicate("░", empty) <> "]" <> suffix line |> String.slice(0, w) |> string_to_cells(x, y, fg, :default) end @doc """ Renders a sparkline from a list of numeric values. Options: - `label` -- prefix label (default: "") - `min` -- explicit minimum (default: auto from data) - `max` -- explicit maximum (default: auto from data) """ @spec render_sparkline(region(), [number()], keyword()) :: [cell()] def render_sparkline(region, values, opts \\ []) def render_sparkline({_x, _y, _w, _h}, [], _opts), do: [] def render_sparkline({x, y, w, _h}, values, opts) do label = Keyword.get(opts, :label, "") data_min = Keyword.get_lazy(opts, :min, fn -> Enum.min(values) end) data_max = Keyword.get_lazy(opts, :max, fn -> Enum.max(values) end) prefix = if label != "", do: "#{label} ", else: "" spark_width = max(w - String.length(prefix), 1) # Take the last spark_width values display_values = Enum.take(values, -spark_width) range = data_max - data_min last_idx = length(@spark_chars) - 1 spark_str = Enum.map_join(display_values, fn v -> normalized = if range > 0, do: (v - data_min) / range, else: 0.5 idx = clamp(round(normalized * last_idx), 0, last_idx) Enum.at(@spark_chars, idx) end) line = prefix <> spark_str string_to_cells(line, x, y, :cyan, :default) end @doc """ Renders a threat indicator with level and bearing. Options: - `label` -- prefix (default: "THREAT") Levels: `:none`, `:low`, `:medium`, `:high`, `:critical` """ @spec render_threat(region(), atom(), number(), keyword()) :: [cell()] def render_threat({x, y, w, _h}, level, bearing_deg, opts \\ []) do label = Keyword.get(opts, :label, "THREAT") {icon, fg} = threat_style(level) bearing_str = bearing_deg |> round() |> Integer.to_string() |> String.pad_leading(3, "0") line = "#{icon} #{label} #{level_str(level)} #{bearing_str}deg" |> String.pad_trailing(w) |> String.slice(0, w) string_to_cells(line, x, y, fg, :default) end @doc """ Renders a minimap using braille dot patterns. Entities: `[%{x: 0.0..1.0, y: 0.0..1.0, char: "x"}]` Maps normalized coordinates into a braille grid (2 dots wide x 4 dots tall per character). Options: - `border` -- draw border (default: true) """ @spec render_minimap(region(), [map()], keyword()) :: [cell()] def render_minimap({x, y, w, h}, entities, opts \\ []) do border = Keyword.get(opts, :border, true) {inner_x, inner_y, inner_w, inner_h} = if border do {x + 1, y + 1, max(w - 2, 1), max(h - 2, 1)} else {x, y, w, h} end # Braille grid: each char is 2 dots wide, 4 dots tall dot_w = inner_w * 2 dot_h = inner_h * 4 # Build dot grid dots = :array.new(dot_w * dot_h, default: false) dots = Enum.reduce(entities, dots, fn entity, acc -> dx = round(Map.get(entity, :x, 0.0) * (dot_w - 1)) dy = round(Map.get(entity, :y, 0.0) * (dot_h - 1)) if dx >= 0 and dx < dot_w and dy >= 0 and dy < dot_h do :array.set(dy * dot_w + dx, true, acc) else acc end end) # Convert dot grid to braille characters braille_cells = for cy <- 0..(inner_h - 1), cx <- 0..(inner_w - 1) do codepoint = braille_codepoint(dots, cx * 2, cy * 4, dot_w) char = <> {inner_x + cx, inner_y + cy, char, :green, :default, %{}} end if border do border_cells(x, y, w, h) ++ braille_cells else braille_cells end end # -- Private -- defp gauge_color(pct, _warn, crit) when pct >= crit, do: :red defp gauge_color(pct, warn, _crit) when pct >= warn, do: :yellow defp gauge_color(_pct, _warn, _crit), do: :green defp threat_style(:none), do: {"[ ]", :green} defp threat_style(:low), do: {"[.]", :green} defp threat_style(:medium), do: {"[*]", :yellow} defp threat_style(:high), do: {"[!]", :red} defp threat_style(:critical), do: {"[!]", :red} defp threat_style(_), do: {"[?]", :white} defp level_str(:none), do: "NONE" defp level_str(:low), do: "LOW" defp level_str(:medium), do: "MED" defp level_str(:high), do: "HIGH" defp level_str(:critical), do: "CRIT" defp level_str(other), do: to_string(other) # Braille dot positions within a character cell: # (0,0) (1,0) bits: 0x01 0x08 # (0,1) (1,1) 0x02 0x10 # (0,2) (1,2) 0x04 0x20 # (0,3) (1,3) 0x40 0x80 @braille_offsets [ {0, 0, 0x01}, {0, 1, 0x02}, {0, 2, 0x04}, {0, 3, 0x40}, {1, 0, 0x08}, {1, 1, 0x10}, {1, 2, 0x20}, {1, 3, 0x80} ] defp braille_codepoint(dots, bx, by, dot_w) do Enum.reduce(@braille_offsets, 0x2800, fn {dx, dy, bit}, acc -> px = bx + dx py = by + dy if :array.get(py * dot_w + px, dots) do Bitwise.bor(acc, bit) else acc end end) end defp border_cells(x, y, w, h) do top = for cx <- 0..(w - 1) do char = cond do cx == 0 -> "┌" cx == w - 1 -> "┐" true -> "─" end {x + cx, y, char, :white, :default, %{}} end bottom = for cx <- 0..(w - 1) do char = cond do cx == 0 -> "└" cx == w - 1 -> "┘" true -> "─" end {x + cx, y + h - 1, char, :white, :default, %{}} end sides = for cy <- 1..(h - 2) do [ {x, y + cy, "│", :white, :default, %{}}, {x + w - 1, y + cy, "│", :white, :default, %{}} ] end top ++ bottom ++ List.flatten(sides) end # Local clamp -- raxol_sensor has zero deps, cannot import from raxol_core defp clamp(val, lo, hi), do: val |> max(lo) |> min(hi) defp string_to_cells(string, x, y, fg, bg) do string |> String.graphemes() |> Enum.with_index() |> Enum.map(fn {char, offset} -> {x + offset, y, char, fg, bg, %{}} end) end end