lib/asciichart.ex

defmodule Asciichart.Charset do
  @enforce_keys [:topleft, :topright, :bottomleft, :bottomright, :dash, :pipe, :axis, :firstval]
  defstruct @enforce_keys

  @moduledoc """
    Defines different character sets for plotting.
  """

  def round do
    %__MODULE__{
      topleft: <<0x256D::utf8>>,
      topright: <<0x256E::utf8>>,
      bottomleft: <<0x2570::utf8>>,
      bottomright: <<0x256F::utf8>>,
      dash: <<0x2500::utf8>>,
      pipe: <<0x2502::utf8>>,
      axis: <<0x2524::utf8>>,
      firstval: <<0x253C::utf8>>
    }
  end

  def square do
    %__MODULE__{
      topleft: <<0x250C::utf8>>,
      topright: <<0x2510::utf8>>,
      bottomleft: <<0x2514::utf8>>,
      bottomright: <<0x2518::utf8>>,
      dash: <<0x2500::utf8>>,
      pipe: <<0x2502::utf8>>,
      axis: <<0x2524::utf8>>,
      firstval: <<0x253C::utf8>>
    }
  end

  def single_char(char) when is_binary(char) do
    if String.length(char) == 1 do
      %__MODULE__{
        topleft: char,
        topright: char,
        bottomleft: char,
        bottomright: char,
        dash: char,
        pipe: char,
        axis: <<0x2524::utf8>>,
        firstval: char
      }
    else
      raise ArgumentError, message: "expected single character but [#{char}] was provided"
    end
  end
end

defmodule Asciichart do
  @moduledoc """
    ASCII chart generation.

    Ported to Elixir from [https://github.com/kroitor/asciichart](https://github.com/kroitor/asciichart)
  """

  @doc ~S"""
  Generates a chart for the specified list of numbers.

  Optionally, the following settings can be provided:
    * :offset - the number of characters to set as the chart's offset (left)
    * :height - adjusts the height of the chart
    * :padding - one or more characters to use for the label's padding (left)
    * :charset - a customizable character set
    * :precision - number of fractional digits to keep for floating-point values

  ## Examples
      iex> Asciichart.plot([1, 2, 3, 3, 2, 1])
      {:ok, "3.00 ┤ ╭─╮   \n2.00 ┤╭╯ ╰╮  \n1.00 ┼╯   ╰  \n          "}

      # should render as

      3.00 ┤ ╭─╮
      2.00 ┤╭╯ ╰╮
      1.00 ┼╯   ╰

      iex> Asciichart.plot([1, 2, 6, 6, 2, 1], height: 2)
      {:ok, "6.00 ┤       \n3.50 ┤ ╭─╮   \n1.00 ┼─╯ ╰─  \n          "}

      # should render as

      6.00 ┤
      3.50 ┤ ╭─╮
      1.00 ┼─╯ ╰─

      iex> Asciichart.plot([1, 2, 5, 5, 4, 3, 2, 100, 0], height: 3, offset: 10, padding: "__")
      {:ok, "    100.00    ┤      ╭╮  \n    _50.00    ┤      ││  \n    __0.00    ┼──────╯╰  \n                    "}

      # should render as

          100.00    ┤      ╭╮
          _50.00    ┤      ││
          __0.00    ┼──────╯╰

      iex> Asciichart.plot([1, 2, 5, 5, 4, 3, 2, 100, 0], height: 3, offset: 10, padding: "__", precision: 3)
      {:ok, "   100.000     ┤      ╭╮  \n   _50.000     ┤      ││  \n   __0.000     ┼──────╯╰  \n                    "}

      # should render as

          100.000     ┤      ╭╮
          _50.000     ┤      ││
          __0.000     ┼──────╯╰


      # Rendering of empty charts is not supported

      iex> Asciichart.plot([])
      {:error, "No data"}
  """

  @spec plot([number], %{optional(atom) => any}) :: String.t()
  def plot(series, cfg \\ %{}) do
    case series do
      [] ->
        {:error, "No data"}

      [_ | _] ->
        minimum = Enum.min(series)
        maximum = Enum.max(series)

        interval = abs(maximum - minimum)
        offset = cfg[:offset] || 3
        height = if cfg[:height], do: cfg[:height] - 1, else: interval
        padding = cfg[:padding] || " "
        charset = if cfg[:charset], do: cfg[:charset], else: Asciichart.Charset.round()
        precision = cfg[:precision] || 2
        ratio = height / interval
        min2 = Float.floor(minimum * ratio)
        max2 = Float.ceil(maximum * ratio)

        intmin2 = trunc(min2)
        intmax2 = trunc(max2)

        rows = abs(intmax2 - intmin2)
        width = length(series) + offset

        # empty space
        result =
          0..(rows + 1)
          |> Enum.map(fn x ->
            {x, 0..width |> Enum.map(fn y -> {y, " "} end) |> Enum.into(%{})}
          end)
          |> Enum.into(%{})

        max_label_size =
          (maximum / 1)
          |> Float.round(precision)
          |> :erlang.float_to_binary(decimals: precision)
          |> String.length()
          |> max(3)

        min_label_size =
          (minimum / 1)
          |> Float.round(precision)
          |> :erlang.float_to_binary(decimals: precision)
          |> String.length()
          |> max(3)

        label_size = max(min_label_size, max_label_size)

        # axis and labels
        result =
          intmin2..intmax2
          |> Enum.reduce(result, fn y, map ->
            label =
              (maximum - (y - intmin2) * interval / rows)
              |> Float.round(precision)
              |> :erlang.float_to_binary(decimals: precision)
              |> String.pad_leading(label_size, padding)

            updated_map = put_in(map[y - intmin2][max(offset - String.length(label), 0)], label)
            put_in(updated_map[y - intmin2][offset - 1], charset.axis)
          end)

        # first value
        y0 = trunc(Enum.at(series, 0) * ratio - min2)
        result = put_in(result[rows - y0][offset - 1], charset.firstval)

        # plot the line
        result =
          0..(length(series) - 2)
          |> Enum.reduce(result, fn x, map ->
            y0 = trunc(Enum.at(series, x + 0) * ratio - intmin2)
            y1 = trunc(Enum.at(series, x + 1) * ratio - intmin2)

            if y0 == y1 do
              put_in(map[rows - y0][x + offset], charset.dash)
            else
              updated_map =
                put_in(
                  map[rows - y1][x + offset],
                  if(y0 > y1, do: charset.bottomleft, else: charset.topleft)
                )

              updated_map =
                put_in(
                  updated_map[rows - y0][x + offset],
                  if(y0 > y1, do: charset.topright, else: charset.bottomright)
                )

              (min(y0, y1) + 1)..max(y0, y1)
              |> Enum.drop(-1)
              |> Enum.reduce(updated_map, fn y, map ->
                put_in(map[rows - y][x + offset], charset.pipe)
              end)
            end
          end)

        # ensures cell order, regardless of map sizes
        result =
          result
          |> Enum.sort_by(fn {k, _} -> k end)
          |> Enum.map(fn {_, x} ->
            x
            |> Enum.sort_by(fn {k, _} -> k end)
            |> Enum.map(fn {_, y} -> y end)
            |> Enum.join()
          end)
          |> Enum.join("\n")

        {:ok, result}
    end
  end
end