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 [ "", generate_slices(chart), "" ] 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""" #{Float.round(value, 2)}% """ else "" end { ~s""" #{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