lib/chart/scale/category_colour_scale.ex

defmodule Contex.CategoryColourScale do
  @moduledoc """
  Maps categories to colours.

  The `Contex.CategoryColourScale` maps categories to a colour palette. It is used, for example, to calculate
  the fill colours for `Contex.BarChart`, or to calculate the colours for series in `Contex.PointPlot`.

  Internally it is a very simple map with some convenience methods to handle duplicated data inputs,
  cycle through colours etc.

  The mapping is done on a first identified, first matched basis from the provided dataset. So, for example,
  if you have a colour palette of `["ff0000", "00ff00", "0000ff"]` (aka red, green, blue), the mapping
  for a dataset would be as follows:

  X | Y | Category | Mapped Colour
  -- | - | -------- | -------------
  0 | 0 | Turtle | red
  1 | 1 | Turtle | red
  0 | 1 | Camel | green
  2 | 1 | Brontosaurus | blue
  3 | 4 | Turtle | red
  5 | 5 | Brontosaurus | blue
  6 | 7 | Hippopotamus | red ← *NOTE* - if you run out of colours, they will cycle

  Tn use, the `CategoryColourScale` is created with a list of values to map to colours and optionally a colour
  palette. If using with a `Contex.Dataset`, it would be initialised like this:

  ```
  dataset = Dataset.new(data, ["X", "Y", "Category"])
  colour_scale
    = dataset
    |> Dataset.unique_values("Category")
    |> CategoryColourScale(["ff0000", "00ff00", "0000ff"])
  ```
  Then it can be used to look up colours for values as needed:

  ```
  fill_colour = CategoryColourScale.colour_for_value(colour_scale, "Brontosaurus") // returns "0000ff"
  ```

  There are a number of built-in colour palettes - see `colour_palette()`, but you can supply your own by
  providing a list of strings representing hex code of the colour as per CSS colour hex codes, but without the #. For example:

    ```
    scale = CategoryColourScale.set_palette(scale, ["fbb4ae", "b3cde3", "ccebc5"])
    ```
  """
  alias __MODULE__

  defstruct [:values, :colour_palette, :colour_map, :default_colour]

  @type t() :: %__MODULE__{}
  @type colour_palette() :: nil | :default | :pastel1 | :warm | list()

  @default_colour "fa8866"

  @doc """
  Create a new CategoryColourScale from a list of values.

  Optionally attach a colour palette.
  Pretty well any value list can be used so long as it can be a key in a map.
  """
  @spec new(list(), colour_palette()) :: Contex.CategoryColourScale.t()
  def new(raw_values, palette \\ :default) when is_list(raw_values) do
    values = Enum.uniq(raw_values)

    %CategoryColourScale{values: values}
    |> set_palette(palette)
  end

  @doc """
  Update the colour palette used for the scale
  """
  @spec set_palette(Contex.CategoryColourScale.t(), colour_palette()) ::
          Contex.CategoryColourScale.t()
  def set_palette(%CategoryColourScale{} = colour_scale, nil),
    do: set_palette(colour_scale, :default)

  def set_palette(%CategoryColourScale{} = colour_scale, palette) when is_atom(palette) do
    set_palette(colour_scale, get_palette(palette))
  end

  def set_palette(%CategoryColourScale{} = colour_scale, palette) when is_list(palette) do
    %{colour_scale | colour_palette: palette}
    |> map_values_to_palette()
  end

  @doc """
  Sets the default colour for the scale when it isn't possible to look one up for a value
  """
  def set_default_colour(%CategoryColourScale{} = colour_scale, colour) do
    %{colour_scale | default_colour: colour}
  end

  @doc """
  Inverts the order of values. Note, the palette is generated from the existing
  colour map so reapplying a palette will result in reversed colours
  """
  def invert(%CategoryColourScale{values: values} = scale) do
    values = Enum.reverse(values)
    palette = Enum.map(values, fn val -> colour_for_value(scale, val) end)

    new(values, palette)
  end

  @doc """
  Look up a colour for a value from the palette.
  """
  @spec colour_for_value(Contex.CategoryColourScale.t() | nil, any()) :: String.t()
  def colour_for_value(nil, _value), do: @default_colour

  def colour_for_value(%CategoryColourScale{colour_map: colour_map} = colour_scale, value) do
    case Map.fetch(colour_map, value) do
      {:ok, result} -> result
      _ -> get_default_colour(colour_scale)
    end
  end

  @doc """
  Get the default colour. Surprise.
  """
  @spec get_default_colour(Contex.CategoryColourScale.t() | nil) :: String.t()
  def get_default_colour(%CategoryColourScale{default_colour: default} = _colour_scale)
      when is_binary(default),
      do: default

  def get_default_colour(_), do: @default_colour

  @doc """
  Create a function to lookup a value from the palette.
  """
  def domain_to_range_fn(%CategoryColourScale{} = scale) do
    # Note, we basically carry a copy of the scale definition - we could
    # probably get smarter than this by pre-mapping values to colours
    # TODO: We could probably implement the Contex.Scale protocol

    fn range_val ->
      CategoryColourScale.colour_for_value(scale, range_val)
    end
  end

  defp map_values_to_palette(
         %CategoryColourScale{values: values, colour_palette: palette} = colour_scale
       ) do
    {_, colour_map} =
      Enum.reduce(values, {0, Map.new()}, fn value, {index, current_result} ->
        colour = get_colour(palette, index)
        {index + 1, Map.put(current_result, value, colour)}
      end)

    %{colour_scale | colour_map: colour_map}
  end

  # "Inspired by" https://github.com/d3/d3-scale-chromatic/blob/master/src/categorical/category10.js
  @default_palette [
    "1f77b4",
    "ff7f0e",
    "2ca02c",
    "d62728",
    "9467bd",
    "8c564b",
    "e377c2",
    "7f7f7f",
    "bcbd22",
    "17becf"
  ]
  defp get_palette(:default), do: @default_palette

  # "Inspired by" https://github.com/d3/d3-scale-chromatic/blob/master/src/categorical/Pastel1.js
  @pastel1_palette [
    "fbb4ae",
    "b3cde3",
    "ccebc5",
    "decbe4",
    "fed9a6",
    "ffffcc",
    "e5d8bd",
    "fddaec",
    "f2f2f2"
  ]
  defp get_palette(:pastel1), do: @pastel1_palette

  # Warm colours - see https://learnui.design/tools/data-color-picker.html#single
  @warm_palette ["d40810", "e76241", "f69877", "ffcab4", "ffeac4", "fffae4"]
  defp get_palette(:warm), do: @warm_palette

  defp get_palette(_), do: nil

  # TODO: We currently cycle the palette when we run out of colours. Probably should fade them (or similar)
  defp get_colour(colour_palette, index) when is_list(colour_palette) do
    palette_length = length(colour_palette)
    adjusted_index = rem(index, palette_length)
    Enum.at(colour_palette, adjusted_index)
  end
end