diff --git a/README.md b/README.md index a40a6d9..13e0854 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,14 @@ Markers are used to highlight specific spots on the chart. There are two types o ### Reference lines Reference lines are used to show common reference lines on the chart. There are four types of -currently supported reference lines (`maximum`, `minimum`, `average`, and `median`) that will -be rendered as horizontal lines. +currently supported reference lines (`maximum`, `minimum`, `average`, `median`, and `percentile/1`) +that will be rendered as horizontal lines. + +## Window + +Normally the window is automatically calculated based on the datapoints. However, you can set the +min or the max value of the window or both to show only a specific part of the chart or to always +display the same amount of data. ### Customization diff --git a/lib/sparkline_svg.ex b/lib/sparkline_svg.ex index ba78a83..fcd455d 100644 --- a/lib/sparkline_svg.ex +++ b/lib/sparkline_svg.ex @@ -127,6 +127,17 @@ defmodule SparklineSvg do See the documentation of `m:SparklineSvg.ReferenceLine` for more information on how to use. + ## Window + + Window option can be used to set the minimum and maximum value of the x axis of the chart. + Normally the window is automatically calculated based on the datapoints. You can set the min or + the max value of the window or both. + + Outside of window datapoints will be discarded before calculation of the reference lines. + + This can be useful to have a consistent chart when the data is not consistent or to have multiple + charts with the same window. + ## Customization SparklineSvg allows you to customize the chart showing or hiding the dots, the line, and the area @@ -246,6 +257,13 @@ defmodule SparklineSvg do [here](https://developer.mozilla.org/en-US/docs/Web/SVG/attributee/stroke-dasharray). - `:class` - the value of the HTML class attribute of the reference line, defaults to `nil`. + ### Window options + + - `:min` - the minimum value of the window, defaults to `:auto`. The value must be of the same + type as the `x` axis of the chart, or `:auto`. + - `:max` - the maximum value of the window, defaults to `:auto`. The value must be of the same + type as the `x` axis of the chart, or `:auto`. + """ alias SparklineSvg.Core @@ -344,6 +362,9 @@ defmodule SparklineSvg do @type ref_line_options :: list({:width, number()} | {:color, String.t()} | {:class, nil | String.t()}) + @typedoc "Keyword list of options for the x window." + @type window_options :: list({:min, :auto | x()} | {:max, :auto | x()}) + @typedoc false @type opt_padding :: %{top: number(), right: number(), bottom: number(), left: number()} @@ -364,15 +385,19 @@ defmodule SparklineSvg do @typedoc false @type ref_lines :: %{optional(ref_line()) => ReferenceLine.t()} + @typedoc false + @type window :: %{min: :auto | x(), max: :auto | x()} + @typedoc false @type t :: %__MODULE__{ datapoints: datapoints(), options: opts(), markers: list(Marker.t()), - ref_lines: ref_lines() + ref_lines: ref_lines(), + window: window() } - @enforce_keys [:datapoints, :options, :markers, :ref_lines] - defstruct [:datapoints, :options, :markers, :ref_lines] + @enforce_keys [:datapoints, :options, :markers, :ref_lines, :window] + defstruct [:datapoints, :options, :markers, :ref_lines, :window] @doc ~S""" Create a new sparkline struct with the given datapoints and options. @@ -414,7 +439,13 @@ defmodule SparklineSvg do |> Map.update!(:padding, &expand_padding/1) |> Map.merge(%{dots: nil, line: nil, area: nil}) - %SparklineSvg{datapoints: datapoints, options: options, markers: [], ref_lines: %{}} + %SparklineSvg{ + datapoints: datapoints, + options: options, + markers: [], + ref_lines: %{}, + window: %{min: :auto, max: :auto} + } end @doc ~S""" @@ -529,6 +560,7 @@ defmodule SparklineSvg do iex> chart = SparklineSvg.new([1, 2]) |> SparklineSvg.show_ref_line(:avg, color: "red") iex> SparklineSvg.to_svg!(chart) ~S'' + """ @doc since: "0.2.0" @@ -541,6 +573,52 @@ defmodule SparklineSvg do %SparklineSvg{sparkline | ref_lines: ref_lines} end + @doc ~S""" + Set the x window of a sparkline struct with the given options. + + The window is automatically calculated based on the datapoints. If you want to set a custom + window, use this function. + + Datapoints outside the window will be removed before rendering and reference lines computations. + + When using this function with a list of numbers as datapoints, the min window value and the max + window must be interpreted as the index of the list. Negative values are allowed. + + ## Examples + + iex> chart = SparklineSvg.new([1, 2, 3, 4]) |> SparklineSvg.show_line() |> SparklineSvg.set_x_window(min: 1, max: 2) + iex> SparklineSvg.to_svg!(chart) + ~S'' + + iex> chart = SparklineSvg.new([1, 2, 3]) |> SparklineSvg.show_line() |> SparklineSvg.set_x_window(min: -1, max: 3) + iex> SparklineSvg.to_svg!(chart) + ~S'' + + iex> now = DateTime.utc_now() + iex> chart = + ...> [{now, 2}, {DateTime.add(now, 1), 3}] + ...> |> SparklineSvg.new() + ...> |> SparklineSvg.show_line() + ...> |> SparklineSvg.set_x_window(min: DateTime.add(now, -1)) + iex> SparklineSvg.to_svg!(chart) + ~S'' + + """ + + @default_window_opts [min: :auto, max: :auto] + + @doc since: "0.5.0" + @spec set_x_window(t()) :: t() + @spec set_x_window(t(), window_options()) :: t() + def set_x_window(sparkline, options \\ []) do + window = + @default_window_opts + |> Keyword.merge(options) + |> Map.new() + + %SparklineSvg{sparkline | window: window} + end + @doc ~S""" Add one or many markers to a sparkline struct with the given options. @@ -672,15 +750,27 @@ defmodule SparklineSvg do @spec compute(t()) :: {:ok, t()} | {:error, atom()} defp compute(sparkline) do - %{width: width, height: height, padding: padding} = sparkline.options + %{ + datapoints: datapoints, + markers: markers, + ref_lines: ref_lines, + window: window, + options: %{width: width, height: height, padding: padding} + } = sparkline with :ok <- check_x_dimension(width, padding), :ok <- check_y_dimension(height, padding), - {:ok, datapoints, type} <- Datapoint.clean(sparkline.datapoints), - {:ok, markers} <- Marker.clean(sparkline.markers, type), - {:ok, ref_lines} <- ReferenceLine.clean(sparkline.ref_lines) do + {:ok, datapoints, window, type} <- Datapoint.clean(datapoints, window), + {:ok, markers} <- Marker.clean(markers, type), + {:ok, ref_lines} <- ReferenceLine.clean(ref_lines) do sparkline = - %SparklineSvg{sparkline | datapoints: datapoints, markers: markers, ref_lines: ref_lines} + %SparklineSvg{ + sparkline + | datapoints: datapoints, + markers: markers, + ref_lines: ref_lines, + window: window + } |> Core.compute() {:ok, sparkline} diff --git a/lib/sparkline_svg/core.ex b/lib/sparkline_svg/core.ex index cae6199..36f68fb 100644 --- a/lib/sparkline_svg/core.ex +++ b/lib/sparkline_svg/core.ex @@ -29,10 +29,20 @@ defmodule SparklineSvg.Core do end def compute(%SparklineSvg{} = sparkline) do - %{datapoints: datapoints, ref_lines: ref_lines, markers: markers, options: options} = - sparkline + %{ + datapoints: datapoints, + ref_lines: ref_lines, + markers: markers, + options: options, + window: window + } = sparkline + + {{min_x, max_x}, min_max_y} = get_min_max(datapoints) + + min_x = if window.min == :auto, do: min_x, else: window.min + max_x = if window.max == :auto, do: max_x, else: window.max - {min_max_x, min_max_y} = get_min_max(datapoints) + min_max_x = {min_x, max_x} %SparklineSvg{ sparkline diff --git a/lib/sparkline_svg/datapoint.ex b/lib/sparkline_svg/datapoint.ex index 408b24c..83852c6 100644 --- a/lib/sparkline_svg/datapoint.ex +++ b/lib/sparkline_svg/datapoint.ex @@ -4,38 +4,44 @@ defmodule SparklineSvg.Datapoint do alias SparklineSvg.Core alias SparklineSvg.Type - @spec clean(SparklineSvg.datapoints()) :: - {:ok, Core.points(), SparklineSvg.x()} | {:error, atom()} - def clean([{_x, _y} | _] = datapoints) do - {datapoints, type} = - Enum.reduce_while(datapoints, {[], nil}, fn - {x, y}, {datapoints, type} -> - with {:ok, x, type} <- Type.cast_x(x, type), - {:ok, y} <- Type.cast_y(y) do - {:cont, {[{x, y} | datapoints], type}} - else - {:error, reason} -> {:halt, {{:error, reason}, type}} - end - - _only_y, {_datapoints, type} -> - {:halt, {{:error, :mixed_datapoints_types}, type}} - end) + @spec clean(SparklineSvg.datapoints(), SparklineSvg.window()) :: + {:ok, Core.points(), SparklineSvg.window(), SparklineSvg.x()} | {:error, atom()} + def clean(datapoints, window) do + {datapoints, type} = ensure_datapoint_type(datapoints) - case datapoints do - {:error, reason} -> - {:error, reason} + with datapoints when is_list(datapoints) <- datapoints, + {:ok, min} <- Type.cast_window(window.min, type), + {:ok, max} <- Type.cast_window(window.max, type) do + window = %{min: min, max: max} - datapoints -> - datapoints = - datapoints - |> Enum.uniq_by(fn {x, _} -> x end) - |> Enum.sort_by(fn {x, _} -> x end) + datapoints = + datapoints + |> maybe_window(window) + |> Enum.uniq_by(fn {x, _} -> x end) + |> Enum.sort_by(fn {x, _} -> x end) - {:ok, datapoints, type} + {:ok, datapoints, window, type} end end - def clean(datapoints) do + @spec ensure_datapoint_type(SparklineSvg.datapoints()) :: + {Core.points(), SparklineSvg.x()} | {{:error, atom()}, SparklineSvg.x()} + defp ensure_datapoint_type([{_x, _y} | _] = datapoints) do + Enum.reduce_while(datapoints, {[], nil}, fn + {x, y}, {datapoints, type} -> + with {:ok, x, type} <- Type.cast_x(x, type), + {:ok, y} <- Type.cast_y(y) do + {:cont, {[{x, y} | datapoints], type}} + else + {:error, reason} -> {:halt, {{:error, reason}, type}} + end + + _only_y, {_datapoints, type} -> + {:halt, {{:error, :mixed_datapoints_types}, type}} + end) + end + + defp ensure_datapoint_type(datapoints) do datapoints = datapoints |> Enum.with_index() @@ -50,9 +56,23 @@ defmodule SparklineSvg.Datapoint do end end) - case datapoints do - {:error, reason} -> {:error, reason} - datapoints -> {:ok, Enum.reverse(datapoints), :number} - end + {datapoints, :number} + end + + @spec maybe_window(Core.points(), SparklineSvg.window()) :: Core.points() + def maybe_window(datapoints, %{min: :auto, max: :auto}) do + datapoints + end + + def maybe_window(datapoints, %{min: min, max: :auto}) do + Enum.filter(datapoints, fn {x, _} -> x >= min end) + end + + def maybe_window(datapoints, %{min: :auto, max: max}) do + Enum.filter(datapoints, fn {x, _} -> x <= max end) + end + + def maybe_window(datapoints, %{min: min, max: max}) do + Enum.filter(datapoints, fn {x, _} -> x >= min and x <= max end) end end diff --git a/lib/sparkline_svg/type.ex b/lib/sparkline_svg/type.ex index 3bb8e34..71dce88 100644 --- a/lib/sparkline_svg/type.ex +++ b/lib/sparkline_svg/type.ex @@ -47,4 +47,17 @@ defmodule SparklineSvg.Type do def cast_y(_y) do {:error, :invalid_y_type} end + + @spec cast_window(:auto | SparklineSvg.x(), Core.x()) :: + {:ok, :auto | Core.x()} | {:error, atom()} + def cast_window(:auto, _type) do + {:ok, :auto} + end + + def cast_window(x, type) do + case cast_x(x, type) do + {:ok, x, _type} -> {:ok, x} + {:error, reason} -> {:error, reason} + end + end end diff --git a/test/sparkline_svg_window_test.exs b/test/sparkline_svg_window_test.exs new file mode 100644 index 0000000..3931eca --- /dev/null +++ b/test/sparkline_svg_window_test.exs @@ -0,0 +1,93 @@ +defmodule SparklineSvgWindowTest do + use ExUnit.Case, async: true + + test "set_x_window/2 with too much data" do + {:ok, sparkline} = + [2, 2, 2] + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(min: 1) + |> SparklineSvg.dry_run() + + assert sparkline.datapoints == [{2.0, 25.0}, {198.0, 25.0}] + + {:ok, sparkline} = + [2, 2, 2] + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(max: 1) + |> SparklineSvg.dry_run() + + assert sparkline.datapoints == [{2.0, 25.0}, {198.0, 25.0}] + + {:ok, sparkline} = + [2, 2, 2, 2, 2, 2, 2] + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(min: 1, max: 2) + |> SparklineSvg.dry_run() + + assert sparkline.datapoints == [{2.0, 25.0}, {198.0, 25.0}] + end + + test "set_x_window/2 with {x, y} data" do + {:ok, sparkline} = + [{1, 2}, {2, 2}, {3, 2}] + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(min: 2) + |> SparklineSvg.dry_run() + + assert sparkline.datapoints == [{2.0, 25.0}, {198.0, 25.0}] + end + + test "set_x_window/2 with hole on left" do + {:ok, sparkline} = + [2, 2] + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(min: -1) + |> SparklineSvg.dry_run() + + assert sparkline.datapoints == [{100.0, 25.0}, {198.0, 25.0}] + end + + test "set_x_window/2 with hole on right" do + {:ok, sparkline} = + [2, 2] + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(max: 2) + |> SparklineSvg.dry_run() + + assert sparkline.datapoints == [{2.0, 25.0}, {100.0, 25.0}] + end + + test "set_x_window/2 with non-number window" do + now = DateTime.utc_now() + + {:ok, sparkline} = + [{now, 1}, {DateTime.add(now, 1), 2}] + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(min: DateTime.add(now, -1)) + |> SparklineSvg.dry_run() + + assert sparkline.datapoints == [{100.0, 48.0}, {198.0, 2.0}] + end + + test "set_x_window/2 with mixed data type" do + now = DateTime.utc_now() + + resp = + [{now, 1}, {DateTime.add(now, 1), 2}] + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(min: Time.utc_now()) + |> SparklineSvg.dry_run() + + assert resp == {:error, :mixed_datapoints_types} + end + + test "set_x_window/2 out-of-bound" do + {:ok, sparkline} = + 1..5 + |> SparklineSvg.new() + |> SparklineSvg.set_x_window(min: -5, max: -1) + |> SparklineSvg.dry_run() + + assert sparkline.datapoints == [] + end +end