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