lib/chart/pointplot.ex

defmodule Contex.PointPlot do
  @moduledoc """
  A simple point plot, plotting points showing y values against x values.

  It is possible to specify multiple y columns with the same x column. It is not
  yet possible to specify multiple independent series.

  The x column can either be numeric or date time data. If numeric, a
  `Contex.ContinuousLinearScale` is used to scale the values to the plot,
  and if date time, a `Contex.TimeScale` is used.

  Fill colours for each y column can be specified with `colours/2`.

  A column in the dataset can optionally be used to control the colours. See
  `colours/2` and `set_colour_col_name/2`
  """

  import Contex.SVG

  alias __MODULE__
  alias Contex.{Scale, ContinuousLinearScale, TimeScale}
  alias Contex.CategoryColourScale
  alias Contex.{Dataset, Mapping}
  alias Contex.Axis
  alias Contex.Utils

  defstruct [
    :dataset,
    :mapping,
    :options,
    :x_scale,
    :y_scale,
    :legend_scale,
    transforms: %{},
    colour_palette: :default
  ]

  @required_mappings [
    x_col: :exactly_one,
    y_cols: :one_or_more,
    fill_col: :zero_or_one
  ]

  @default_options [
    axis_label_rotation: :auto,
    custom_x_scale: nil,
    custom_y_scale: nil,
    custom_x_formatter: nil,
    custom_y_formatter: nil,
    width: 100,
    height: 100,
    colour_palette: :default
  ]

  @default_plot_options %{
    show_x_axis: true,
    show_y_axis: true,
    legend_setting: :legend_none
  }

  @type t() :: %__MODULE__{}

  @doc ~S"""
  Create a new point plot definition and apply defaults.

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

    - `:axis_label_rotation` : `:auto` (default), 45 or 90

  Specifies the label rotation value that will be applied to the bottom axis. Accepts integer
  values for degrees of rotation or `:auto`. Note that manually set rotation values other than
  45 or 90 will be treated as zero. The default value is `:auto`, which sets the rotation to
  zero degrees if the number of items on the axis is greater than eight, 45 degrees otherwise.

    - `:custom_x_scale` : `nil` (default) or an instance of a suitable `Contex.Scale`.

    The scale must be suitable for the data type and would typically be either `Contex.ContinuousLinearScale`
    or `Contex.TimeScale`. It is not necessary to set the range for the scale as the range is set
    as part of the chart layout process.

    - `:custom_y_scale` : `nil` (default) or an instance of a suitable `Contex.Scale`.

    - `:custom_x_formatter` : `nil` (default) or a function with arity 1

  Allows the axis tick labels to be overridden. For example, if you have a numeric representation of money and you want to
  have the x axis show it as millions of dollars you might do something like:

        # Turns 1_234_567.67 into $1.23M
        defp money_formatter_millions(value) when is_number(value) do
          "$#{:erlang.float_to_binary(value/1_000_000.0, [decimals: 2])}M"
        end

        defp show_chart(data) do
          PointPlot.new(
            dataset,
            mapping: %{x_col: :column_a, y_cols: [:column_b, column_c]},
            custom_x_formatter: &money_formatter_millions/1
          )
        end

    - `:custom_y_formatter` : `nil` (default) or a function with arity 1.
    - `:colour_palette` : `:default` (default) or colour palette - see `colours/2`

  Overrides the default colours.

  Where multiple y columns are defined for the plot, a different colour will be used for
  each column.

  If a single y column is defined and a `:fill_col`column is mapped,
  a different colour will be used for each unique value in the colour column.

  If a single y column is defined and no `:fill_col`column is mapped, the first colour
  in the supplied colour palette will be used to plot the points.

  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:

    ```
    chart = PointPlot.new(
        dataset,
        mapping: %{x_col: :column_a, y_cols: [:column_b, column_c]},
        colour_palette: ["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`

    - `:mapping` : Maps attributes required to generate the barchart to columns in the dataset.

  If the data in the dataset is stored as a map, the `:mapping` option is required. If the dataset
  is not stored as a map, `:mapping` may be left out, in which case the first column will be used
  for the x and the second column used as the y.
  This value must be a map of the plot's `:x_col` and `:y_cols` to keys in the map,
  such as `%{x_col: :column_a, y_cols: [:column_b, column_c]}`.
  The value for the `:y_cols` key must be a list.

  If a single y column is specified an optional `:fill_col` mapping can be provided
  to control the point colour. _This is ignored if there are multiple y columns_.

  """
  @spec new(Contex.Dataset.t(), keyword()) :: Contex.PointPlot.t()
  def new(%Dataset{} = dataset, options \\ []) do
    options = Keyword.merge(@default_options, options)
    mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset)

    %PointPlot{dataset: dataset, mapping: mapping, options: options}
  end

  @doc """
  Sets the default scales for the plot based on its column mapping.
  """
  @deprecated "Default scales are now silently applied"
  @spec set_default_scales(Contex.PointPlot.t()) :: Contex.PointPlot.t()
  def set_default_scales(%PointPlot{mapping: %{column_map: column_map}} = plot) do
    set_x_col_name(plot, column_map.x_col)
    |> set_y_col_names(column_map.y_cols)
  end

  @doc """
  Set the colour palette for fill colours.

  Where multiple y columns are defined for the plot, a different colour will be used for
  each column.

  If a single y column is defined and a colour column is defined (see `set_colour_col_name/2`),
  a different colour will be used for each unique value in the colour column.

  If a single y column is defined and no colour column is defined, the first colour
  in the supplied colour palette will be used to plot the points.
  """
  @deprecated "Set in new/2 options"
  @spec colours(Contex.PointPlot.t(), Contex.CategoryColourScale.colour_palette()) ::
          Contex.PointPlot.t()
  def colours(plot, colour_palette) when is_list(colour_palette) or is_atom(colour_palette) do
    set_option(plot, :colour_palette, colour_palette)
  end

  def colours(plot, _) do
    set_option(plot, :colour_palette, :default)
  end

  @doc """
  Specifies the label rotation value that will be applied to the bottom axis. Accepts integer
  values for degrees of rotation or `:auto`. Note that manually set rotation values other than
  45 or 90 will be treated as zero. The default value is `:auto`, which sets the rotation to
  zero degrees if the number of items on the axis is greater than eight, 45 degrees otherwise.
  """
  @deprecated "Set in new/2 options"
  @spec axis_label_rotation(Contex.PointPlot.t(), integer() | :auto) :: Contex.PointPlot.t()
  def axis_label_rotation(%PointPlot{} = plot, rotation) when is_integer(rotation) do
    set_option(plot, :axis_label_rotation, rotation)
  end

  def axis_label_rotation(%PointPlot{} = plot, _) do
    set_option(plot, :axis_label_rotation, :auto)
  end

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

  @doc ~S"""
  Allows the axis tick labels to be overridden. For example, if you have a numeric representation of money and you want to
  have the value axis show it as millions of dollars you might do something like:

        # Turns 1_234_567.67 into $1.23M
        defp money_formatter_millions(value) when is_number(value) do
          "$#{:erlang.float_to_binary(value/1_000_000.0, [decimals: 2])}M"
        end

        defp show_chart(data) do
          PointPlot.new(data)
          |> PointPlot.custom_x_formatter(&money_formatter_millions/1)
        end

  """
  @deprecated "Set in new/2 options"
  @spec custom_x_formatter(Contex.PointPlot.t(), nil | fun) :: Contex.PointPlot.t()
  def custom_x_formatter(%PointPlot{} = plot, custom_x_formatter)
      when is_function(custom_x_formatter) or custom_x_formatter == nil do
    set_option(plot, :custom_x_formatter, custom_x_formatter)
  end

  @doc ~S"""
  Allows the axis tick labels to be overridden. For example, if you have a numeric representation of money and you want to
  have the value axis show it as millions of dollars you might do something like:

        # Turns 1_234_567.67 into $1.23M
        defp money_formatter_millions(value) when is_number(value) do
          "$#{:erlang.float_to_binary(value/1_000_000.0, [decimals: 2])}M"
        end

        defp show_chart(data) do
          PointPlot.new(data)
          |> PointPlot.custom_y_formatter(&money_formatter_millions/1)
        end

  """
  @deprecated "Set in new/2 options"
  @spec custom_y_formatter(Contex.PointPlot.t(), nil | fun) :: Contex.PointPlot.t()
  def custom_y_formatter(%PointPlot{} = plot, custom_y_formatter)
      when is_function(custom_y_formatter) or custom_y_formatter == nil do
    set_option(plot, :custom_y_formatter, custom_y_formatter)
  end

  @doc """
  Specify which column in the dataset is used for the x values.

  This column must contain numeric or date time data.
  """
  @deprecated "Use `:mapping` option in `new/2`"
  @spec set_x_col_name(Contex.PointPlot.t(), Contex.Dataset.column_name()) :: Contex.PointPlot.t()
  def set_x_col_name(%PointPlot{mapping: mapping} = plot, x_col_name) do
    mapping = Mapping.update(mapping, %{x_col: x_col_name})

    %{plot | mapping: mapping}
  end

  @doc """
  Specify which column(s) in the dataset is/are used for the y values.

  These columns must contain numeric data.

  Where more than one y column is specified the colours are used to identify data from
  each column.
  """
  @deprecated "Use `:mapping` option in `new/2`"
  @spec set_y_col_names(Contex.PointPlot.t(), [Contex.Dataset.column_name()]) ::
          Contex.PointPlot.t()
  def set_y_col_names(%PointPlot{mapping: mapping} = plot, y_col_names)
      when is_list(y_col_names) do
    mapping = Mapping.update(mapping, %{y_cols: y_col_names})

    %{plot | mapping: mapping}
  end

  @doc """
  If a single y column is specified, it is possible to use another column to control the point colour.

  Note: This is ignored if there are multiple y columns.
  """
  @deprecated "Use `:mapping` option in `new/2`"
  @spec set_colour_col_name(Contex.PointPlot.t(), Contex.Dataset.column_name()) ::
          Contex.PointPlot.t()
  def set_colour_col_name(%PointPlot{} = plot, nil), do: plot

  def set_colour_col_name(%PointPlot{mapping: mapping} = plot, fill_col_name) do
    mapping = Mapping.update(mapping, %{fill_col: fill_col_name})
    %{plot | mapping: mapping}
  end

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

    %{plot | options: options}
  end

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

  @doc false
  def get_legend_scales(%PointPlot{} = plot) do
    plot = prepare_scales(plot)
    [plot.legend_scale]
  end

  def get_legend_scales(_), do: []

  @doc false
  def to_svg(%PointPlot{} = plot, plot_options) do
    plot = prepare_scales(plot)
    x_scale = plot.x_scale
    y_scale = plot.y_scale

    plot_options = Map.merge(@default_plot_options, plot_options)

    x_axis_svg =
      if plot_options.show_x_axis,
        do:
          get_x_axis(x_scale, plot)
          |> Axis.to_svg(),
        else: ""

    y_axis_svg =
      if plot_options.show_y_axis,
        do:
          Axis.new_left_axis(y_scale)
          |> Axis.set_offset(get_option(plot, :width))
          |> Axis.to_svg(),
        else: ""

    [
      x_axis_svg,
      y_axis_svg,
      "<g>",
      get_svg_points(plot),
      "</g>"
    ]
  end

  defp get_x_axis(x_scale, plot) do
    rotation =
      case get_option(plot, :axis_label_rotation) do
        :auto ->
          if length(Scale.ticks_range(x_scale)) > 8, do: 45, else: 0

        degrees ->
          degrees
      end

    x_scale
    |> Axis.new_bottom_axis()
    |> Axis.set_offset(get_option(plot, :height))
    |> Kernel.struct(rotation: rotation)
  end

  defp get_svg_points(%PointPlot{dataset: dataset} = plot) do
    dataset.data
    |> Enum.map(fn row -> get_svg_point(plot, row) end)
  end

  defp get_svg_point(
         %PointPlot{
           mapping: %{accessors: accessors},
           transforms: transforms
         },
         row
       ) do
    x =
      accessors.x_col.(row)
      |> transforms.x.()

    fill_val = accessors.fill_col.(row)

    Enum.with_index(accessors.y_cols)
    |> Enum.map(fn {accessor, index} ->
      val = accessor.(row)

      case val do
        nil ->
          ""

        _ ->
          y = transforms.y.(val)
          fill = transforms.colour.(index, fill_val)
          get_svg_point(x, y, fill)
      end
    end)
  end

  defp get_svg_point(x, y, fill) when is_number(x) and is_number(y) do
    circle(x, y, 3, fill: fill)
  end

  defp get_svg_point(_x, _y, _fill), do: ""

  @doc false
  def prepare_scales(%PointPlot{} = plot) do
    plot
    |> prepare_x_scale()
    |> prepare_y_scale()
    |> prepare_colour_scale()
  end

  defp prepare_x_scale(%PointPlot{dataset: dataset, mapping: mapping} = plot) do
    x_col_name = mapping.column_map[:x_col]
    width = get_option(plot, :width)
    custom_x_scale = get_option(plot, :custom_x_scale)

    x_scale =
      case custom_x_scale do
        nil -> create_scale_for_column(dataset, x_col_name, {0, width})
        _ -> custom_x_scale |> Scale.set_range(0, width)
      end

    x_scale = %{x_scale | custom_tick_formatter: get_option(plot, :custom_x_formatter)}
    x_transform = Scale.domain_to_range_fn(x_scale)
    transforms = Map.merge(plot.transforms, %{x: x_transform})

    %{plot | x_scale: x_scale, transforms: transforms}
  end

  defp prepare_y_scale(%PointPlot{dataset: dataset, mapping: mapping} = plot) do
    y_col_names = mapping.column_map[:y_cols]
    height = get_option(plot, :height)
    custom_y_scale = get_option(plot, :custom_y_scale)

    y_scale =
      case custom_y_scale do
        nil ->
          {min, max} =
            get_overall_domain(dataset, y_col_names)
            |> Utils.fixup_value_range()

          ContinuousLinearScale.new()
          |> ContinuousLinearScale.domain(min, max)
          |> Scale.set_range(height, 0)

        _ ->
          custom_y_scale |> Scale.set_range(height, 0)
      end

    y_scale = %{y_scale | custom_tick_formatter: get_option(plot, :custom_y_formatter)}
    y_transform = Scale.domain_to_range_fn(y_scale)
    transforms = Map.merge(plot.transforms, %{y: y_transform})

    %{plot | y_scale: y_scale, transforms: transforms}
  end

  defp prepare_colour_scale(%PointPlot{dataset: dataset, mapping: mapping} = plot) do
    y_col_names = mapping.column_map[:y_cols]
    fill_col_name = mapping.column_map[:fill_col]
    palette = get_option(plot, :colour_palette)

    # It's a little tricky. We look up colours by index when colouring by series
    # but need the legend by column name, so where we are colouring by series
    # we will create a transform function with one instance of a colour scale
    # and the legend from another

    legend_scale = create_legend_colour_scale(y_col_names, fill_col_name, dataset, palette)

    transform = create_colour_transform(y_col_names, fill_col_name, dataset, palette)
    transforms = Map.merge(plot.transforms, %{colour: transform})

    %{plot | legend_scale: legend_scale, transforms: transforms}
  end

  defp create_legend_colour_scale(y_col_names, fill_col_name, dataset, palette)
       when length(y_col_names) == 1 and not is_nil(fill_col_name) do
    vals = Dataset.unique_values(dataset, fill_col_name)
    CategoryColourScale.new(vals) |> CategoryColourScale.set_palette(palette)
  end

  defp create_legend_colour_scale(y_col_names, _fill_col_name, _dataset, palette) do
    CategoryColourScale.new(y_col_names) |> CategoryColourScale.set_palette(palette)
  end

  defp create_colour_transform(y_col_names, fill_col_name, dataset, palette)
       when length(y_col_names) == 1 and not is_nil(fill_col_name) do
    vals = Dataset.unique_values(dataset, fill_col_name)
    scale = CategoryColourScale.new(vals) |> CategoryColourScale.set_palette(palette)

    fn _col_index, fill_val -> CategoryColourScale.colour_for_value(scale, fill_val) end
  end

  defp create_colour_transform(y_col_names, _fill_col_name, _dataset, palette) do
    fill_indices =
      Enum.with_index(y_col_names)
      |> Enum.map(fn {_, index} -> index end)

    scale = CategoryColourScale.new(fill_indices) |> CategoryColourScale.set_palette(palette)

    fn col_index, _fill_val -> CategoryColourScale.colour_for_value(scale, col_index) end
  end

  defp get_overall_domain(dataset, col_names) do
    combiner = fn {min1, max1}, {min2, max2} ->
      {Utils.safe_min(min1, min2), Utils.safe_max(max1, max2)}
    end

    Enum.reduce(col_names, {nil, nil}, fn col, acc_extents ->
      inner_extents = Dataset.column_extents(dataset, col)
      combiner.(acc_extents, inner_extents)
    end)
  end

  defp create_scale_for_column(dataset, column, {r_min, r_max}) do
    {min, max} = Dataset.column_extents(dataset, column)

    case Dataset.guess_column_type(dataset, column) do
      :datetime ->
        TimeScale.new()
        |> TimeScale.domain(min, max)
        |> Scale.set_range(r_min, r_max)

      :number ->
        ContinuousLinearScale.new()
        |> ContinuousLinearScale.domain(min, max)
        |> Scale.set_range(r_min, r_max)
    end
  end
end