lib/chart/pie_chart.ex

defmodule Contex.PieChart do
  @moduledoc """
  A Pie Chart that displays data in a circular graph.

  The pieces of the graph are proportional to the fraction of the whole in each category.
  Each slice of the pie is relative to the size of that category in the group as a whole.
  The entire “pie” represents 100 percent of a whole, while the pie “slices” represent portions of the whole.

  Fill colours for each slice can be specified with `colour_palette` parameter in chart options, or can be
  applied from a `CategoryColourScale` suppled in the `colour_scale` parameter. If neither option is supplied
  a default colour palette is used.
  """

  alias __MODULE__
  alias Contex.{Dataset, Mapping, CategoryColourScale}

  defstruct [
    :dataset,
    :mapping,
    :options,
    :colour_scale
  ]

  @type t() :: %__MODULE__{}

  @required_mappings [
    category_col: :zero_or_one,
    value_col: :zero_or_one
  ]

  @default_options [
    width: 600,
    height: 400,
    colour_palette: :default,
    colour_scale: nil,
    data_labels: true
  ]

  @doc """
  Create a new PieChart struct from Dataset.

  Options may be passed to control the settings for the barchart. Options available are:

    - `:data_labels` : `true` (default) or false - display labels for each slice value
    - `:colour_palette` : `:default` (default) or colour palette - see `colours/2`

  An example:
        data = [
          ["Cat", 10.0],
          ["Dog", 20.0],
          ["Hamster", 5.0]
        ]

        dataset = Dataset.new(data, ["Pet", "Preference"])

        opts = [
          mapping: %{category_col: "Pet", value_col: "Preference"},
          colour_palette: ["fbb4ae", "b3cde3", "ccebc5"],
          legend_setting: :legend_right,
          data_labels: false,
          title: "Why dogs are better than cats"
        ]

        Contex.Plot.new(dataset, Contex.PieChart, 600, 400, opts)
  """
  def new(%Dataset{} = dataset, options \\ []) when is_list(options) do
    options = check_options(options)
    options = Keyword.merge(@default_options, options)
    mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset)

    %PieChart{
      dataset: dataset,
      mapping: mapping,
      options: options,
      colour_scale: Keyword.get(options, :colour_scale)
    }
  end

  defp check_options(options) do
    colour_scale = check_colour_scale(Keyword.get(options, :colour_scale))
    Keyword.put(options, :colour_scale, colour_scale)
  end

  defp check_colour_scale(%CategoryColourScale{} = scale), do: scale
  defp check_colour_scale(_), do: nil

  @doc false
  def set_size(%PieChart{} = chart, width, height) do
    chart
    |> set_option(:width, width)
    |> set_option(:height, height)
  end

  @doc false
  def get_legend_scales(%PieChart{} = chart) do
    [get_colour_palette(chart)]
  end

  @doc """
  Overrides the default colours.

  Colours can either be a named palette defined in `Contex.CategoryColourScale` or a list of strings representing hex code
  of the colour as per CSS colour hex codes, but without the #. For example:

    ```
    barchart = BarChart.colours(barchart, ["fbb4ae", "b3cde3", "ccebc5"])
    ```

    The colours will be applied to the data series in the same order as the columns are specified in `set_val_col_names/2`
  """
  @deprecated "Set in new/2 options"
  @spec colours(PieChart.t(), Contex.CategoryColourScale.colour_palette()) ::
          PieChart.t()
  def colours(%PieChart{} = chart, colour_palette) when is_list(colour_palette) do
    set_option(chart, :colour_palette, colour_palette)
  end

  def colours(%PieChart{} = chart, colour_palette) when is_atom(colour_palette) do
    set_option(chart, :colour_palette, colour_palette)
  end

  def colours(%PieChart{} = chart, _) do
    set_option(chart, :colour_palette, :default)
  end

  @doc """
  Renders the PieChart to svg, including the svg wrapper, as a string or improper string list that
  is marked safe.
  """
  def to_svg(%PieChart{} = chart) do
    [
      "<g>",
      generate_slices(chart),
      "</g>"
    ]
  end

  def get_categories(%PieChart{dataset: dataset, mapping: mapping}) do
    cat_accessor = dataset |> Dataset.value_fn(mapping.column_map[:category_col])

    dataset.data
    |> Enum.map(&cat_accessor.(&1))
  end

  defp set_option(%PieChart{options: options} = plot, key, value) do
    options = Keyword.put(options, key, value)

    %{plot | options: options}
  end

  defp get_option(%PieChart{options: options}, key) do
    Keyword.get(options, key)
  end

  defp get_colour_palette(%PieChart{colour_scale: colour_scale}) when not is_nil(colour_scale) do
    colour_scale
  end

  defp get_colour_palette(%PieChart{} = chart) do
    get_categories(chart)
    |> CategoryColourScale.new()
    |> CategoryColourScale.set_palette(get_option(chart, :colour_palette))
  end

  defp generate_slices(%PieChart{} = chart) do
    height = get_option(chart, :height)
    with_labels? = get_option(chart, :data_labels)
    colour_palette = get_colour_palette(chart)

    r = height / 2
    stroke_circumference = 2 * :math.pi() * r / 2

    scale_values(chart)
    |> Enum.map_reduce({0, 0}, fn {value, category}, {idx, offset} ->
      text_rotation = rotate_for(value, offset)

      label =
        if with_labels? do
          ~s"""
            <text x="#{negate_if_flipped(r, text_rotation)}"
                  y="#{negate_if_flipped(r, text_rotation)}"
              text-anchor="middle"
              fill="white"
              stroke-width="1"
              transform="rotate(#{text_rotation},#{r},#{r})
                         translate(#{r / 2}, #{negate_if_flipped(5, text_rotation)})
                         #{if need_flip?(text_rotation), do: "scale(-1,-1)"}"
            >
              #{Float.round(value, 2)}%
            </text>
          """
        else
          ""
        end

      {
        ~s"""
          <circle r="#{r / 2}" cx="#{r}" cy="#{r}" fill="transparent"
            stroke="##{CategoryColourScale.colour_for_value(colour_palette, category)}"
            stroke-width="#{r}"
            stroke-dasharray="#{slice_value(value, stroke_circumference)} #{stroke_circumference}"
            stroke-dashoffset="-#{slice_value(offset, stroke_circumference)}">
          </circle>
          #{label}
        """,
        {idx + 1, offset + value}
      }
    end)
    |> elem(0)
    |> Enum.join()
  end

  defp slice_value(value, stroke_circumference) do
    value * stroke_circumference / 100
  end

  defp rotate_for(n, offset) do
    n / 2 * 3.6 + offset * 3.6
  end

  defp need_flip?(rotation) do
    90 < rotation and rotation < 270
  end

  defp negate_if_flipped(number, rotation) do
    if need_flip?(rotation),
      do: -number,
      else: number
  end

  @spec scale_values(PieChart.t()) :: [{value :: number(), label :: any()}]
  defp scale_values(%PieChart{dataset: dataset, mapping: mapping}) do
    val_accessor = dataset |> Dataset.value_fn(mapping.column_map[:value_col])
    cat_accessor = dataset |> Dataset.value_fn(mapping.column_map[:category_col])

    sum = dataset.data |> Enum.reduce(0, fn col, acc -> val_accessor.(col) + acc end)

    dataset.data
    |> Enum.map_reduce(sum, &{{val_accessor.(&1) / &2 * 100, cat_accessor.(&1)}, &2})
    |> elem(0)
  end
end