TermUI.Widgets.VisualizationHelper (TermUI v0.2.0)

View Source

Shared utilities for visualization widgets (charts, gauges, sparklines).

Provides common functions for:

  • Value normalization and scaling
  • Number formatting
  • Color/zone threshold mapping
  • Min/max range calculation
  • Input validation
  • Style application

Usage

alias TermUI.Widgets.VisualizationHelper, as: VizHelper

# Normalize a value to 0-1 range
VizHelper.normalize(75, 0, 100)
#=> 0.75

# Format numbers for display
VizHelper.format_number(3.14159)
#=> "3.1"

# Find style based on threshold zones
zones = [{0, :green}, {60, :yellow}, {80, :red}]
VizHelper.find_zone(85, zones)
#=> :red

Summary

Functions

Calculates min/max range from data, with optional overrides.

Clamps height to safe bounds.

Clamps width to safe bounds.

Gets a color from a list by cycling through indices.

Finds the appropriate style/color for a value based on threshold zones.

Formats a numeric value for display.

Returns the maximum allowed height for visualization widgets.

Returns the maximum allowed width for visualization widgets.

Applies style conditionally to a render node.

Normalizes a value to 0-1 range based on min/max bounds. Clamps result to [0, 1].

Normalizes and scales a value in one step.

Safely duplicates a string with bounds checking.

Scales a normalized value (0-1) to a target size.

Validates bar chart data structure.

Validates that a character is a single printable character.

Validates that a value is a number.

Validates that all values in a list are numbers.

Validates line chart series data structure.

Functions

calculate_range(values, opts \\ [])

@spec calculate_range(
  [number()],
  keyword()
) :: {number(), number()}

Calculates min/max range from data, with optional overrides.

Examples

iex> VisualizationHelper.calculate_range([1, 5, 3, 9, 2])
{1, 9}

iex> VisualizationHelper.calculate_range([1, 5, 3], min: 0)
{0, 5}

iex> VisualizationHelper.calculate_range([1, 5, 3], min: 0, max: 10)
{0, 10}

iex> VisualizationHelper.calculate_range([])
{0, 1}

clamp_height(height)

@spec clamp_height(integer()) :: pos_integer()

Clamps height to safe bounds.

Examples

iex> VisualizationHelper.clamp_height(20)
20

iex> VisualizationHelper.clamp_height(1000)
500

iex> VisualizationHelper.clamp_height(-5)
1

clamp_width(width)

@spec clamp_width(integer()) :: pos_integer()

Clamps width to safe bounds.

Examples

iex> VisualizationHelper.clamp_width(50)
50

iex> VisualizationHelper.clamp_width(2000)
1000

iex> VisualizationHelper.clamp_width(-5)
1

cycle_color(colors, index)

@spec cycle_color([any()], non_neg_integer()) :: any() | nil

Gets a color from a list by cycling through indices.

Examples

iex> colors = [:red, :blue, :green]
iex> VisualizationHelper.cycle_color(colors, 0)
:red

iex> colors = [:red, :blue, :green]
iex> VisualizationHelper.cycle_color(colors, 4)
:blue

iex> VisualizationHelper.cycle_color([], 0)
nil

find_zone(value, zones)

@spec find_zone(number(), [{number(), any()}]) :: any() | nil

Finds the appropriate style/color for a value based on threshold zones.

Zones should be a list of {threshold, style} tuples. The function returns the style associated with the highest threshold that is <= the value.

Examples

iex> zones = [{0, :green}, {60, :yellow}, {80, :red}]
iex> VisualizationHelper.find_zone(50, zones)
:green

iex> zones = [{0, :green}, {60, :yellow}, {80, :red}]
iex> VisualizationHelper.find_zone(75, zones)
:yellow

iex> zones = [{0, :green}, {60, :yellow}, {80, :red}]
iex> VisualizationHelper.find_zone(90, zones)
:red

iex> VisualizationHelper.find_zone(50, [])
nil

format_number(value)

@spec format_number(any()) :: String.t()

Formats a numeric value for display.

  • Floats are formatted to 1 decimal place
  • Integers are converted to string
  • Other values return "???"

Examples

iex> VisualizationHelper.format_number(42)
"42"

iex> VisualizationHelper.format_number(3.14159)
"3.1"

iex> VisualizationHelper.format_number(:not_a_number)
"???"

max_height()

@spec max_height() :: pos_integer()

Returns the maximum allowed height for visualization widgets.

max_width()

@spec max_width() :: pos_integer()

Returns the maximum allowed width for visualization widgets.

maybe_style(node, style)

@spec maybe_style(any(), any()) :: any()

Applies style conditionally to a render node.

Returns the node unchanged if style is nil.

Examples

iex> node = %{type: :text, content: "hello"}
iex> VisualizationHelper.maybe_style(node, nil)
%{type: :text, content: "hello"}

normalize(value, min, max)

@spec normalize(number(), number(), number()) :: float()

Normalizes a value to 0-1 range based on min/max bounds. Clamps result to [0, 1].

Returns 0.5 when min equals max to avoid division by zero.

Examples

iex> VisualizationHelper.normalize(50, 0, 100)
0.5

iex> VisualizationHelper.normalize(75, 0, 100)
0.75

iex> VisualizationHelper.normalize(150, 0, 100)
1.0

iex> VisualizationHelper.normalize(-10, 0, 100)
0.0

iex> VisualizationHelper.normalize(50, 50, 50)
0.5

normalize_and_scale(value, min, max, target_size)

@spec normalize_and_scale(number(), number(), number(), number()) :: integer()

Normalizes and scales a value in one step.

Examples

iex> VisualizationHelper.normalize_and_scale(50, 0, 100, 20)
10

iex> VisualizationHelper.normalize_and_scale(75, 0, 100, 40)
30

safe_duplicate(string, count)

@spec safe_duplicate(String.t(), integer()) :: String.t()

Safely duplicates a string with bounds checking.

Prevents memory exhaustion by clamping count to reasonable bounds.

Examples

iex> VisualizationHelper.safe_duplicate("█", 5)
"█████"

iex> VisualizationHelper.safe_duplicate("█", -5)
""

iex> VisualizationHelper.safe_duplicate("█", 10000)
# Returns string with max_width characters

scale(normalized, target_size)

@spec scale(float(), number()) :: integer()

Scales a normalized value (0-1) to a target size.

Examples

iex> VisualizationHelper.scale(0.5, 100)
50

iex> VisualizationHelper.scale(0.75, 20)
15

validate_bar_data(data)

@spec validate_bar_data(any()) :: :ok | {:error, String.t()}

Validates bar chart data structure.

Each item must be a map with :label (string) and :value (number) keys.

Examples

iex> data = [%{label: "A", value: 10}, %{label: "B", value: 20}]
iex> VisualizationHelper.validate_bar_data(data)
:ok

iex> VisualizationHelper.validate_bar_data([%{label: "A"}])
{:error, "bar data item at index 0 missing :value key"}

iex> VisualizationHelper.validate_bar_data("not a list")
{:error, "expected a list of bar data items"}

validate_char(char)

@spec validate_char(any()) :: :ok | {:error, String.t()}

Validates that a character is a single printable character.

Examples

iex> VisualizationHelper.validate_char("█")
:ok

iex> VisualizationHelper.validate_char("ab")
{:error, "expected a single character, got 2 characters"}

iex> VisualizationHelper.validate_char("")
{:error, "expected a single character, got empty string"}

validate_number(value)

@spec validate_number(any()) :: :ok | {:error, String.t()}

Validates that a value is a number.

Examples

iex> VisualizationHelper.validate_number(42)
:ok

iex> VisualizationHelper.validate_number(3.14)
:ok

iex> VisualizationHelper.validate_number("not a number")
{:error, "expected a number, got: \"not a number\""}

validate_number_list(values)

@spec validate_number_list(any()) :: :ok | {:error, String.t()}

Validates that all values in a list are numbers.

Examples

iex> VisualizationHelper.validate_number_list([1, 2, 3])
:ok

iex> VisualizationHelper.validate_number_list([1, "two", 3])
{:error, "all values must be numbers, found non-number at index 1"}

iex> VisualizationHelper.validate_number_list("not a list")
{:error, "expected a list of numbers"}

validate_series_data(series)

@spec validate_series_data(any()) :: :ok | {:error, String.t()}

Validates line chart series data structure.

Each series must be a map with :data (list of numbers) and optional :color keys.

Examples

iex> series = [%{data: [1, 2, 3]}, %{data: [4, 5, 6], color: :red}]
iex> VisualizationHelper.validate_series_data(series)
:ok

iex> VisualizationHelper.validate_series_data([%{data: "not a list"}])
{:error, "series at index 0 :data must be a list of numbers"}