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,
"<g>",
get_svg_lines(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_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