Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add window #33

Merged
merged 6 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
108 changes: 99 additions & 9 deletions lib/sparkline_svg.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()}

Expand All @@ -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.
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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'<svg width="100%" height="100%" viewBox="0 0 200 50" xmlns="http://www.w3.org/2000/svg"><line x1="2" y1="25.0" x2="198" y2="25.0" fill="none" stroke="red" stroke-width="0.25" /></svg>'

"""

@doc since: "0.2.0"
Expand All @@ -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'<svg width="100%" height="100%" viewBox="0 0 200 50" xmlns="http://www.w3.org/2000/svg"><path d="M2.0,48.0C31.4,41.1 168.6,8.9 198.0,2.0" fill="none" stroke="black" stroke-width="0.25" /></svg>'

iex> chart = SparklineSvg.new([1, 2, 3]) |> SparklineSvg.show_line() |> SparklineSvg.set_x_window(min: -1, max: 3)
iex> SparklineSvg.to_svg!(chart)
~S'<svg width="100%" height="100%" viewBox="0 0 200 50" xmlns="http://www.w3.org/2000/svg"><path d="M51.0,48.0C58.35,44.55 85.3,31.9 100.0,25.0C114.7,18.1 141.65,5.45 149.0,2.0" fill="none" stroke="black" stroke-width="0.25" /></svg>'

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'<svg width="100%" height="100%" viewBox="0 0 200 50" xmlns="http://www.w3.org/2000/svg"><path d="M100.0,48.0C114.7,41.1 183.3,8.9 198.0,2.0" fill="none" stroke="black" stroke-width="0.25" /></svg>'

"""

@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.

Expand Down Expand Up @@ -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}
Expand Down
16 changes: 13 additions & 3 deletions lib/sparkline_svg/core.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 50 additions & 30 deletions lib/sparkline_svg/datapoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
13 changes: 13 additions & 0 deletions lib/sparkline_svg/type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading