defmodule Contex.Sparkline do
@moduledoc """
Generates a simple sparkline from an array of numbers.
Note that this does not follow the pattern for other types of plot. It is not designed
to be embedded within a `Contex.Plot` and, because it only relies on a single list
of numbers, does not use data wrapped in a `Contex.Dataset`.
Usage is exceptionally simple:
```
data = [0, 5, 10, 15, 12, 12, 15, 14, 20, 14, 10, 15, 15]
Sparkline.new(data) |> Sparkline.draw() # Emits svg sparkline
```
The colour defaults to a green line with a faded green fill, but can be overridden
with `colours/3`. Unlike other colours in Contex, these colours are how you would
specify them in CSS - e.g.
```
Sparkline.new(data)
|> Sparkline.colours("#fad48e", "#ff9838")
|> Sparkline.draw()
```
The size defaults to 20 pixels high and 100 wide. You can override by updating
`:height` and `:width` directly in the `Sparkline` struct before call `draw/1`.
"""
alias __MODULE__
alias Contex.{ContinuousLinearScale, Scale}
defstruct [
:data,
:extents,
:length,
:spot_radius,
:spot_colour,
:line_width,
:line_colour,
:fill_colour,
:y_transform,
:height,
:width
]
@type t() :: %__MODULE__{}
@doc """
Create a new sparkline struct from some data.
"""
@spec new([number()]) :: Contex.Sparkline.t()
def new(data) when is_list(data) do
%Sparkline{data: data, extents: ContinuousLinearScale.extents(data), length: length(data)}
|> set_default_style
end
@doc """
Override line and fill colours for the sparkline.
Note that colours should be specified as you would in CSS - they are passed through
directly into the SVG. For example:
```
Sparkline.new(data)
|> Sparkline.colours("#fad48e", "#ff9838")
|> Sparkline.draw()
```
"""
@spec colours(Contex.Sparkline.t(), String.t(), String.t()) :: Contex.Sparkline.t()
def colours(%Sparkline{} = sparkline, fill, line) do
# TODO: Really need some validation...
%{sparkline | fill_colour: fill, line_colour: line}
end
defp set_default_style(%Sparkline{} = sparkline) do
%{
sparkline
| spot_radius: 2,
spot_colour: "red",
line_width: 1,
line_colour: "rgba(0, 200, 50, 0.7)",
fill_colour: "rgba(0, 200, 50, 0.2)",
height: 20,
width: 100
}
end
@doc """
Renders the sparkline to svg, including the svg wrapper, as a string or improper string list that
is marked safe.
"""
def draw(%Sparkline{height: height, width: width, line_width: line_width} = sparkline) do
vb_width = sparkline.length + 1
vb_height = height - 2 * line_width
scale =
ContinuousLinearScale.new()
|> ContinuousLinearScale.domain(sparkline.data)
|> Scale.set_range(vb_height, 0)
sparkline = %{sparkline | y_transform: Scale.domain_to_range_fn(scale)}
output = ~s"""
<svg height="#{height}" width="#{width}" viewBox="0 0 #{vb_width} #{vb_height}" preserveAspectRatio="none" role="img">
<path d="#{get_closed_path(sparkline, vb_height)}" #{get_fill_style(sparkline)}></path>
<path d="#{get_path(sparkline)}" #{get_line_style(sparkline)}></path>
</svg>
"""
{:safe, [output]}
end
defp get_line_style(%Sparkline{line_colour: line_colour, line_width: line_width}) do
~s|stroke="#{line_colour}" stroke-width="#{line_width}" fill="none" vector-effect="non-scaling-stroke"|
end
defp get_fill_style(%Sparkline{fill_colour: fill_colour}) do
~s|stroke="none" fill="#{fill_colour}"|
end
defp get_closed_path(%Sparkline{} = sparkline, vb_height) do
# Same as the open path, except we drop down, run back to height,height (aka 0,0) and close it...
open_path = get_path(sparkline)
[open_path, "V #{vb_height} L 0 #{vb_height} Z"]
end
# This is the IO List approach
defp get_path(%Sparkline{y_transform: transform_func} = sparkline) do
last_item = Enum.count(sparkline.data) - 1
[
"M",
sparkline.data
|> Enum.map(transform_func)
|> Enum.with_index()
|> Enum.map(fn {value, i} ->
case i < last_item do
true -> "#{i} #{value} L "
_ -> "#{i} #{value}"
end
end)
]
end
end