defmodule Contex.LinePlot 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. Data are sorted by the x-value prior to plotting. 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, smoothed: true, stroke_width: "2", 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 LinePlot.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. - `:stroke_width` : 2 (default) - stroke width of the line - `:smoothed` : true (default) or false - draw the lines smoothed Note that the smoothing algorithm is a cardinal spline with tension = 0.3. You may get strange effects (e.g. loops / backtracks) in certain circumstances, e.g. if the x-value spacing is very uneven. This alogorithm forces the smoothed line through the points. - `: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 = LinePlot.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.LinePlot.t() def new(%Dataset{} = dataset, options \\ []) do options = Keyword.merge(@default_options, options) mapping = Mapping.new(@required_mappings, Keyword.get(options, :mapping), dataset) %LinePlot{dataset: dataset, mapping: mapping, options: options} end @doc false def set_size(%LinePlot{} = plot, width, height) do plot |> set_option(:width, width) |> set_option(:height, height) end defp set_option(%LinePlot{options: options} = plot, key, value) do options = Keyword.put(options, key, value) %{plot | options: options} end defp get_option(%LinePlot{options: options}, key) do Keyword.get(options, key) end @doc false def get_legend_scales(%LinePlot{} = plot) do plot = prepare_scales(plot) [plot.legend_scale] end def get_legend_scales(_), do: [] @doc false def to_svg(%LinePlot{} = 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, "", get_svg_lines(plot), "" ] 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_lines( %LinePlot{dataset: dataset, mapping: %{accessors: accessors}, transforms: transforms} = plot ) do x_accessor = accessors.x_col # Pre-sort by x-value else we get squiggly lines data = Enum.sort(dataset.data, fn a, b -> x_accessor.(a) > x_accessor.(b) end) Enum.with_index(accessors.y_cols) |> Enum.map(fn {y_accessor, index} -> colour = transforms.colour.(index, nil) get_svg_line(plot, data, y_accessor, colour) end) end defp get_svg_line( %LinePlot{mapping: %{accessors: accessors}, transforms: transforms} = plot, data, y_accessor, colour ) do smooth = get_option(plot, :smoothed) stroke_width = get_option(plot, :stroke_width) options = [ transparent: true, stroke: colour, stroke_width: stroke_width, stroke_linejoin: "round" ] points_list = data |> Stream.map(fn row -> x = accessors.x_col.(row) |> transforms.x.() y = y_accessor.(row) |> transforms.y.() {x, y} end) |> Enum.filter(fn {x, _y} -> not is_nil(x) end) |> Enum.sort(fn {x1, _y1}, {x2, _y2} -> x1 < x2 end) |> Enum.chunk_by(fn {_x, y} -> is_nil(y) end) |> Enum.filter(fn [{_x, y} | _] -> not is_nil(y) end) Enum.map(points_list, fn points -> line(points, smooth, options) end) end @doc false def prepare_scales(%LinePlot{} = plot) do plot |> prepare_x_scale() |> prepare_y_scale() |> prepare_colour_scale() end defp prepare_x_scale(%LinePlot{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(%LinePlot{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(%LinePlot{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