lib/luminous/panel/chart.ex

defmodule Luminous.Panel.Chart do
  alias Luminous.Query

  @behaviour Luminous.Panel
  @impl true
  def transform(%Query.Result{rows: rows, attrs: attrs}) when is_list(rows) do
    # first, let's see if there's a specified ordering in var attrs
    order =
      Enum.reduce(attrs, %{}, fn {label, attrs}, acc ->
        Map.put(acc, label, attrs.order)
      end)

    rows
    |> extract_labels()
    |> Enum.map(fn label ->
      data =
        Enum.map(rows, fn row ->
          {x, y} =
            case row do
              # row is a list of {label, value} tuples
              l when is_list(l) ->
                x =
                  case Keyword.get(row, :time) do
                    %DateTime{} = time -> DateTime.to_unix(time, :millisecond)
                    _ -> nil
                  end

                y =
                  Enum.find_value(l, fn
                    {^label, value} -> value
                    _ -> nil
                  end)

                {x, y}

              # row is a map where labels map to values
              m when is_map(m) ->
                x =
                  case Map.get(row, :time) do
                    %DateTime{} = time -> DateTime.to_unix(time, :millisecond)
                    _ -> nil
                  end

                {x, Map.get(m, label)}

              # row is a single number
              n when is_number(n) ->
                {nil, n}

              _ ->
                raise "Can not process data row #{inspect(row)}"
            end

          case {x, y} do
            {nil, y} -> %{y: convert_to_decimal(y)}
            {x, y} -> %{x: x, y: convert_to_decimal(y)}
          end
        end)
        |> Enum.reject(&is_nil(&1.y))

      attrs =
        Map.get(attrs, label) ||
          Map.get(attrs, to_string(label)) ||
          Query.Attributes.define()

      %{
        rows: data,
        label: to_string(label),
        attrs: attrs,
        stats: statistics(data, to_string(label))
      }
    end)
    |> Enum.sort_by(fn dataset -> order[dataset.label] end)
  end

  def statistics(rows, label) do
    init_stats = %{n: 0, sum: nil, min: nil, max: nil, max_decimal_digits: 0}

    stats =
      Enum.reduce(rows, init_stats, fn %{y: y}, stats ->
        min = Map.fetch!(stats, :min) || y
        max = Map.fetch!(stats, :max) || y
        sum = Map.fetch!(stats, :sum)
        n = Map.fetch!(stats, :n)
        max_decimal_digits = Map.fetch!(stats, :max_decimal_digits)

        new_sum =
          case {sum, y} do
            {nil, y} -> y
            {sum, nil} -> sum
            {sum, y} -> Decimal.add(y, sum)
          end

        decimal_digits =
          with y when not is_nil(y) <- y,
               [_, dec] <- Decimal.to_string(y, :normal) |> String.split(".") do
            String.length(dec)
          else
            _ -> 0
          end

        stats
        |> Map.put(:min, if(!is_nil(y) && Decimal.lt?(y, min), do: y, else: min))
        |> Map.put(:max, if(!is_nil(y) && Decimal.gt?(y, max), do: y, else: max))
        |> Map.put(:sum, new_sum)
        |> Map.put(:n, if(is_nil(y), do: n, else: n + 1))
        |> Map.put(
          :max_decimal_digits,
          if(decimal_digits > max_decimal_digits, do: decimal_digits, else: max_decimal_digits)
        )
      end)

    # we use this to determine the rounding for the average dataset value
    max_decimal_digits = Map.fetch!(stats, :max_decimal_digits)

    # calculate the average
    avg =
      cond do
        stats[:n] == 0 ->
          nil

        is_nil(stats[:sum]) ->
          nil

        true ->
          Decimal.div(stats[:sum], Decimal.new(stats[:n])) |> Decimal.round(max_decimal_digits)
      end

    stats
    |> Map.put(:avg, avg)
    |> Map.put(:label, label)
    |> Map.delete(:max_decimal_digits)
  end

  defp convert_to_decimal(nil), do: nil

  defp convert_to_decimal(value) do
    case Decimal.cast(value) do
      {:ok, dec} -> dec
      _ -> value
    end
  end

  # example: [{:time, #DateTime<2022-10-01 01:00:00+00:00 UTC UTC>}, {"foo", #Decimal<0.65>}]
  defp extract_labels(rows) when is_list(rows) do
    rows
    |> Enum.flat_map(fn
      row ->
        row
        |> Enum.map(fn {label, _value} -> label end)
        |> Enum.reject(&(&1 == :time))
    end)
    |> Enum.uniq()
  end
end