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""" """ {: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