defmodule Contex.OrdinalScale do @moduledoc """ An ordinal scale to map discrete values (text or numeric) to a plotting coordinate system. An ordinal scale is commonly used for the category axis in barcharts. It has to be able to generate the centre-point of the bar (e.g. for tick annotations) as well as the available width the bar or bar-group has to fill. In order to do that the ordinal scale requires a 'padding' option to be set (defaults to 0.5 in the scale) that defines the gaps between the bars / categories. The ordinal scale has two mapping functions for the data domain to the plotting range. One returns the centre point (`range_to_domain_fn`) and one returns the "band" the category can occupy (`domain_to_range_band_fn`). An `OrdinalScale` is initialised with a list of values which represent the categories. The scale generates a tick for each value in that list. Typical usage of this scale would be as follows: iex> category_scale ...> = Contex.OrdinalScale.new(["Hippo", "Turtle", "Rabbit"]) ...> |> Contex.Scale.set_range(0.0, 9.0) ...> |> Contex.OrdinalScale.padding(2) ...> category_scale.domain_to_range_fn.("Turtle") 4.5 iex> category_scale.domain_to_range_band_fn.("Hippo") {1.0, 2.0} iex> category_scale.domain_to_range_band_fn.("Turtle") {4.0, 5.0} """ alias __MODULE__ defstruct [ :domain, :range, :padding, :domain_to_range_fn, :range_to_domain_fn, :domain_to_range_band_fn ] @type t() :: %__MODULE__{} @doc """ Creates a new ordinal scale. """ @spec new(list()) :: Contex.OrdinalScale.t() def new(domain) when is_list(domain) do %OrdinalScale{domain: domain, padding: 0.5} end @doc """ Updates the domain data for the scale. """ @spec domain(Contex.OrdinalScale.t(), list()) :: Contex.OrdinalScale.t() def domain(%OrdinalScale{} = ordinal_scale, data) when is_list(data) do %{ordinal_scale | domain: data} |> update_transform_funcs() end @doc """ Sets the padding between the categories for the scale. Defaults to 0.5. Defined in terms of plotting coordinates. *Note* that if the padding is greater than the calculated width of each category you might get strange effects (e.g. the end of a band being before the beginning) """ def padding(%OrdinalScale{} = scale, padding) when is_number(padding) do # We need to update the transform functions if we change the padding as the band calculations need it %{scale | padding: padding} |> update_transform_funcs() end @doc false def update_transform_funcs( %OrdinalScale{domain: domain, range: {start_r, end_r}, padding: padding} = scale ) when is_list(domain) and is_number(start_r) and is_number(end_r) and is_number(padding) do domain_count = Kernel.length(domain) range_width = end_r - start_r item_width = case domain_count do 0 -> 0.0 _ -> range_width / domain_count end flip_padding = case start_r < end_r do true -> 1.0 _ -> -1.0 end # Returns centre point of bucket domain_to_range_fn = fn domain_val -> case Enum.find_index(domain, fn x -> x == domain_val end) do nil -> start_r index -> start_r + item_width / 2.0 + index * item_width end end domain_to_range_band_fn = fn domain_val -> case Enum.find_index(domain, fn x -> x == domain_val end) do nil -> {start_r, start_r} index -> band_start = start_r + flip_padding * padding / 2.0 + index * item_width band_end = start_r + (index + 1) * item_width - flip_padding * padding / 2.0 {band_start, band_end} end end range_to_domain_fn = case range_width do 0 -> fn -> "" end _ -> fn range_val -> case domain_count do 0 -> "" _ -> bucket_index = Kernel.trunc((range_val - start_r) / item_width) Enum.at(domain, bucket_index) end end end %{ scale | domain_to_range_fn: domain_to_range_fn, range_to_domain_fn: range_to_domain_fn, domain_to_range_band_fn: domain_to_range_band_fn } end def update_transform_funcs(%OrdinalScale{} = scale), do: scale @doc """ Returns the band for the nominated category in terms of plotting coordinate system. If the category isn't found, the start of the plotting range is returned. """ @spec get_band(Contex.OrdinalScale.t(), any) :: {number(), number()} def get_band(%OrdinalScale{domain_to_range_band_fn: domain_to_range_band_fn}, domain_value) when is_function(domain_to_range_band_fn) do domain_to_range_band_fn.(domain_value) end defimpl Contex.Scale do def ticks_domain(%OrdinalScale{domain: domain}), do: domain def ticks_range(%OrdinalScale{domain_to_range_fn: transform_func} = scale) when is_function(transform_func) do ticks_domain(scale) |> Enum.map(transform_func) end def domain_to_range_fn(%OrdinalScale{domain_to_range_fn: domain_to_range_fn}), do: domain_to_range_fn def domain_to_range(%OrdinalScale{domain_to_range_fn: transform_func}, range_val) when is_function(transform_func) do transform_func.(range_val) end def get_range(%OrdinalScale{range: {min_r, max_r}}), do: {min_r, max_r} def set_range(%OrdinalScale{} = scale, start, finish) when is_number(start) and is_number(finish) do %{scale | range: {start, finish}} |> OrdinalScale.update_transform_funcs() end def set_range(%OrdinalScale{} = scale, {start, finish}) when is_number(start) and is_number(finish), do: set_range(scale, start, finish) def get_formatted_tick(_, tick_val), do: tick_val end end