lib/luminous/panel/chart.ex

defmodule Luminous.Panel.Chart do
  alias Luminous.{Attributes, Dashboard, Panel, Utils}

  use Panel

  @impl true
  def data_attributes(),
    do: [
      type: [type: :atom, default: :line],
      fill: [type: :boolean, default: true],
      order: [type: :non_neg_integer, default: 0]
    ]

  @impl true
  def panel_attributes(),
    do: [
      xlabel: [type: :string, default: ""],
      ylabel: [type: :string, default: ""],
      stacked_x: [type: :boolean, default: false],
      stacked_y: [type: :boolean, default: false],
      y_min_value: [type: {:or, [:integer, :float, nil]}, default: nil],
      y_max_value: [type: {:or, [:integer, :float, nil]}, default: nil],
      hook: [type: :string, default: "ChartJSHook"]
    ]

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

    data
    |> extract_labels()
    |> Enum.map(fn label ->
      data =
        Enum.map(data, 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(panel.data_attributes, label) ||
          Map.get(panel.data_attributes, to_string(label)) ||
          Attributes.parse!([], data_attributes() ++ Attributes.Schema.data())

      %{
        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

  @impl true
  def reduce(datasets, panel, dashboard) do
    %{
      datasets: datasets,
      ylabel: panel.ylabel,
      xlabel: panel.xlabel,
      stacked_x: panel.stacked_x,
      stacked_y: panel.stacked_y,
      y_min_value: panel.y_min_value,
      y_max_value: panel.y_max_value,
      time_zone: dashboard.time_zone
    }
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div class="w-full ">
      <div id={"#{Utils.dom_id(@panel)}-container"} phx-update="ignore">
        <canvas
          id={Utils.dom_id(@panel)}
          time-range-selector-id={@dashboard.time_range_selector.id}
          phx-hook={@panel.hook}
        >
        </canvas>
      </div>
      <%= if data = Dashboard.get_data(@dashboard, @panel.id) do %>
        <.panel_statistics stats={Enum.map(data.datasets, & &1.stats)} />
      <% end %>
    </div>
    """
  end

  @impl true
  def actions() do
    [
      %{event: "download:csv", label: "Download CSV"},
      %{event: "download:png", label: "Download Image"}
    ]
  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

  attr :stats, :map, required: true

  def panel_statistics(assigns) do
    if is_nil(assigns.stats) || length(assigns.stats) == 0 do
      ~H""
    else
      ~H"""
      <div class="grid grid-cols-10 gap-x-4 mt-2 mx-8 text-right text-xs">
        <div class="col-span-5 text-xs font-semibold"></div>
        <div class="font-semibold">N</div>
        <div class="font-semibold">Min</div>
        <div class="font-semibold">Max</div>
        <div class="font-semibold">Avg</div>
        <div class="font-semibold">Total</div>

        <%= for var <- @stats do %>
          <div class="col-span-5 truncate"><%= var.label %></div>
          <div><%= var.n %></div>
          <div><%= Utils.print_number(var.min) %></div>
          <div><%= Utils.print_number(var.max) %></div>
          <div><%= Utils.print_number(var.avg) %></div>
          <div><%= Utils.print_number(var.sum) %></div>
        <% end %>
      </div>
      """
    end
  end
end