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