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, :x_scale, :y_scale, :fill_scale, transforms: %{}, axis_label_rotation: :auto, width: 100, height: 100, colour_palette: :default ] @required_mappings [ x_col: :exactly_one, y_cols: :one_or_more, fill_col: :zero_or_one ] @type t() :: %__MODULE__{} @doc """ Create a new point plot definition and apply defaults. If the data in the dataset is stored as a list of maps, the `:series_mapping` option is required. 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 `:y_cols` value must be a list. Optionally a `:fill_col` mapping can be provided, which is equivalent to `set_colour_col_name/2` """ @spec new(Contex.Dataset.t(), keyword()) :: Contex.PointPlot.t() def new(%Dataset{} = dataset, options \\ []) do mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset) %PointPlot{dataset: dataset, mapping: mapping} |> set_default_scales() |> set_colour_col_name(mapping.column_map[:fill_col]) end @doc """ Sets the default scales for the plot based on its column mapping. """ @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. """ @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 %{plot | colour_palette: colour_palette} |> set_y_col_names(plot.mapping.column_map.y_cols) end def colours(plot, _) do %{plot | colour_palette: :default} |> set_y_col_names(plot.mapping.column_map.y_cols) 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. """ @spec axis_label_rotation(Contex.PointPlot.t(), integer() | :auto) :: Contex.PointPlot.t() def axis_label_rotation(%PointPlot{} = plot, rotation) when is_integer(rotation) do %{plot | axis_label_rotation: rotation} end def axis_label_rotation(%PointPlot{} = plot, _) do %{plot | axis_label_rotation: :auto} end @doc false def set_size(%PointPlot{mapping: %{column_map: column_map}} = plot, width, height) do # We pretend to set the x & y columns to force a recalculation of scales - may be expensive. # We only really need to set the range, not recalculate the domain %{plot | width: width, height: height} |> set_x_col_name(column_map.x_col) |> set_y_col_names(column_map.y_cols) end @doc false def get_svg_legend( %PointPlot{mapping: %{column_map: %{y_cols: y_cols, fill_col: fill_col}}} = plot ) when length(y_cols) > 0 or is_nil(fill_col) do # We do the point plotting with an index to look up the colours. For the legend we need the names series_fill_colours = CategoryColourScale.new(y_cols) |> CategoryColourScale.set_palette(plot.colour_palette) Contex.Legend.to_svg(series_fill_colours) end def get_svg_legend(%PointPlot{fill_scale: scale}) do Contex.Legend.to_svg(scale) end def get_svg_legend(_), do: "" @doc false def to_svg(%PointPlot{x_scale: x_scale, y_scale: y_scale} = plot) do axis_x = get_x_axis(x_scale, plot) axis_y = Axis.new_left_axis(y_scale) |> Axis.set_offset(plot.width) [ Axis.to_svg(axis_x), Axis.to_svg(axis_y), "", get_svg_points(plot), "" ] end defp get_x_axis(x_scale, plot) do rotation = case 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(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_line(%PointPlot{dataset: dataset, x_scale: x_scale, y_scale: y_scale} = plot) do # x_col_index = Dataset.column_index(dataset, plot.x_col) # y_col_index = Dataset.column_index(dataset, plot.y_col) # x_tx_fn = Scale.domain_to_range_fn(x_scale) # y_tx_fn = Scale.domain_to_range_fn(y_scale) # style = ~s|stroke="red" stroke-width="2" fill="none" stroke-dasharray="13,2" stroke-linejoin="round" | # last_item = Enum.count(dataset.data) - 1 # path = ["M", # dataset.data # |> Stream.map(fn row -> # x = Dataset.value(row, x_col_index) # y = Dataset.value(row, y_col_index) # {x_tx_fn.(x), y_tx_fn.(y)} # end) # |> Stream.with_index() # |> Enum.map(fn {{x_plot, y_plot}, i} -> # case i < last_item do # true -> ~s|#{x_plot} #{y_plot} L | # _ -> ~s|#{x_plot} #{y_plot}| # end # end) # ] # [~s|"] # end defp get_svg_point( %PointPlot{ mapping: %{accessors: accessors, column_map: %{y_cols: y_cols}}, transforms: transforms, fill_scale: fill_scale }, row ) when length(y_cols) == 1 do x = accessors.x_col.(row) |> transforms.x.() y = hd(accessors.y_cols).(row) |> transforms.y.() fill_data = case accessors.fill_col.(row) do nil -> 0 val -> val end fill = CategoryColourScale.colour_for_value(fill_scale, fill_data) get_svg_point(x, y, fill) end defp get_svg_point( %PointPlot{ mapping: %{accessors: accessors}, transforms: transforms, fill_scale: fill_scale }, row ) do x = accessors.x_col.(row) |> transforms.x.() Enum.with_index(accessors.y_cols) |> Enum.map(fn {accessor, index} -> y = accessor.(row) |> transforms.y.() fill = CategoryColourScale.colour_for_value(fill_scale, index) get_svg_point(x, y, fill) 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 """ Specify which column in the dataset is used for the x values. This column must contain numeric or date time data. """ @spec set_x_col_name(Contex.PointPlot.t(), Contex.Dataset.column_name()) :: Contex.PointPlot.t() def set_x_col_name( %PointPlot{dataset: dataset, width: width, mapping: mapping} = plot, x_col_name ) do mapping = Mapping.update(mapping, %{x_col: x_col_name}) x_scale = create_scale_for_column(dataset, x_col_name, {0, width}) x_transform = Scale.domain_to_range_fn(x_scale) transforms = Map.merge(plot.transforms, %{x: x_transform}) %{plot | x_scale: x_scale, transforms: transforms, 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. """ @spec set_y_col_names(Contex.PointPlot.t(), [Contex.Dataset.column_name()]) :: Contex.PointPlot.t() def set_y_col_names( %PointPlot{dataset: dataset, height: height, mapping: mapping} = plot, y_col_names ) when is_list(y_col_names) do mapping = Mapping.update(mapping, %{y_cols: y_col_names}) {min, max} = get_overall_domain(dataset, y_col_names) |> Utils.fixup_value_range() y_scale = ContinuousLinearScale.new() |> ContinuousLinearScale.domain(min, max) |> Scale.set_range(height, 0) y_transform = Scale.domain_to_range_fn(y_scale) transforms = Map.merge(plot.transforms, %{y: y_transform}) fill_indices = Enum.with_index(y_col_names) |> Enum.map(fn {_, index} -> index end) series_fill_colours = CategoryColourScale.new(fill_indices) |> CategoryColourScale.set_palette(plot.colour_palette) %{ plot | y_scale: y_scale, transforms: transforms, fill_scale: series_fill_colours, mapping: mapping } 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 @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. """ @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{dataset: dataset, mapping: mapping} = plot, fill_col_name) do mapping = Mapping.update(mapping, %{fill_col: fill_col_name}) vals = Dataset.unique_values(dataset, fill_col_name) colour_scale = CategoryColourScale.new(vals) %{plot | fill_scale: colour_scale, mapping: mapping} end end