defmodule Kino.VegaLite do
@moduledoc """
A kino wrapping [VegaLite](https://hexdocs.pm/vega_lite) graphic.
This kino allow for rendering regular VegaLite graphic and then
streaming new data points to update the graphic.
## Examples
chart =
Vl.new(width: 400, height: 400)
|> Vl.mark(:line)
|> Vl.encode_field(:x, "x", type: :quantitative)
|> Vl.encode_field(:y, "y", type: :quantitative)
|> Kino.VegaLite.render()
for i <- 1..300 do
point = %{x: i / 10, y: :math.sin(i / 10)}
Kino.VegaLite.push(chart, point)
Process.sleep(25)
end
"""
use Kino.JS, assets_path: "lib/assets/vega_lite/build"
use Kino.JS.Live
@type t :: Kino.JS.Live.t()
@doc """
Creates a new kino with the given VegaLite definition.
"""
@spec new(VegaLite.t()) :: t()
def new(vl) when is_struct(vl, VegaLite) do
Kino.JS.Live.new(__MODULE__, vl)
end
@doc false
@spec static(VegaLite.t()) :: Kino.JS.t()
def static(vl) when is_struct(vl, VegaLite) do
data = %{
spec: VegaLite.to_spec(vl),
datasets: [],
config: config()
}
Kino.JS.new(__MODULE__, data,
export: fn data -> {"vega-lite", data.spec} end,
# TODO: remove legacy export attributes once we require Kino v0.11.0
export_info_string: "vega-lite",
export_key: :spec
)
end
@doc """
Applies global configuration options for the VegaLite kinos.
## Options
* `:theme` - the theme to be applied on the rendered VegaLite
charts. Currently the only supported theme is `:livebook`. If
set to `nil`, no theme is applied. Defaults to `:livebook`.
"""
@spec configure(keyword()) :: :ok
def configure(opts) do
opts = Keyword.validate!(opts, theme: :livebook)
unless opts[:theme] in [nil, :livebook] do
raise ArgumentError,
"expected :theme to be either :livebook or nil, got: #{inspect(opts[:theme])}"
end
Application.put_all_env(kino_vega_lite: opts)
end
@doc """
Renders and returns a new kino with the given VegaLite definition.
It is equivalent to:
vega_lite |> Kino.VegaLite.new() |> Kino.render()
"""
@spec render(VegaLite.t()) :: t()
def render(vl) when is_struct(vl, VegaLite) do
vl |> new() |> Kino.render()
end
@doc """
Appends a single data point to the graphic dataset.
## Options
* `:window` - the maximum number of data points to keep.
This option is useful when you are appending new
data points to the plot over a long period of time
* `dataset` - name of the targeted dataset from
the VegaLite specification. Defaults to the default
anonymous dataset
"""
@spec push(t(), map(), keyword()) :: :ok
def push(kino, data_point, opts \\ []) do
dataset = opts[:dataset]
window = opts[:window]
data_point = Map.new(data_point)
Kino.JS.Live.cast(kino, {:push, dataset, [data_point], window})
end
@doc """
Appends a number of data points to the graphic dataset.
See `push/3` for more details.
"""
@spec push_many(t(), list(map()), keyword()) :: :ok
def push_many(kino, data_points, opts \\ []) when is_list(data_points) do
dataset = opts[:dataset]
window = opts[:window]
data_points = Enum.map(data_points, &Map.new/1)
Kino.JS.Live.cast(kino, {:push, dataset, data_points, window})
end
@doc """
Updates a vega-lite [parameter's](https://vega.github.io/vega-lite/docs/parameter.html#variable-parameters) value.
The parameter must be registered: `VegaLite.param(vl, "param_name", opts)`.
To use the parameter in the chart, set a property to `[expr: "param_name"]`.
## Examples
chart =
VegaLite.new(width: 400, height: 400)
|> VegaLite.param("stroke_width", value: 3)
|> VegaLite.mark(:line, stroke_width: [expr: "stroke_width"])
|> VegaLite.encode_field(:x, "x", type: :quantitative)
|> VegaLite.encode_field(:y, "y", type: :quantitative)
|> Kino.VegaLite.new()
|> Kino.render()
Kino.VegaLite.set_param(chart, "stroke_width", 10)
"""
@spec set_param(t(), String.t(), term()) :: :ok
def set_param(kino, name, value) do
Kino.JS.Live.cast(kino, {:set_param, name, value})
end
@doc """
Removes all data points from the graphic dataset.
## Options
* `dataset` - name of the targeted dataset from
the VegaLite specification. Defaults to the default
anonymous dataset
"""
@spec clear(t(), keyword()) :: :ok
def clear(kino, opts \\ []) do
dataset = opts[:dataset]
Kino.JS.Live.cast(kino, {:clear, dataset})
end
@doc """
Registers a callback to run periodically in the kino process.
The callback is run every `interval_ms` milliseconds and receives
the accumulated value. The callback should return either of:
* `{:cont, acc}` - the continue with the new accumulated value
* `:halt` - to no longer schedule callback evaluation
The callback is run for the first time immediately upon registration.
"""
@deprecated "Use Kino.listen/3 instead"
@spec periodically(t(), pos_integer(), term(), (term() -> {:cont, term()} | :halt)) :: :ok
def periodically(kino, interval_ms, acc, fun) do
Kino.JS.Live.cast(kino, {:periodically, interval_ms, acc, fun})
end
@impl true
def init(vl, ctx) do
{:ok, assign(ctx, vl: vl, datasets: %{}, config: config())}
end
@compile {:no_warn_undefined, {VegaLite, :to_spec, 1}}
@impl true
def handle_connect(ctx) do
data = %{
spec: VegaLite.to_spec(ctx.assigns.vl),
datasets: for({dataset, data} <- ctx.assigns.datasets, do: [dataset, data]),
config: ctx.assigns.config
}
{:ok, data, ctx}
end
@impl true
def handle_cast({:push, dataset, data, window}, ctx) do
broadcast_event(ctx, "push", %{data: data, dataset: dataset, window: window})
ctx =
update(ctx, :datasets, fn datasets ->
{current_data, datasets} = Map.pop(datasets, dataset, [])
new_data =
if window do
Enum.take(current_data ++ data, -window)
else
current_data ++ data
end
Map.put(datasets, dataset, new_data)
end)
{:noreply, ctx}
end
def handle_cast({:clear, dataset}, ctx) do
broadcast_event(ctx, "push", %{data: [], dataset: dataset, window: 0})
ctx = update(ctx, :datasets, &Map.delete(&1, dataset))
{:noreply, ctx}
end
def handle_cast({:set_param, name, value}, ctx) do
broadcast_event(ctx, "set_param", %{name: name, value: value})
{:noreply, ctx}
end
def handle_cast({:periodically, interval_ms, acc, fun}, state) do
periodically_iter(interval_ms, acc, fun)
{:noreply, state}
end
@impl true
def handle_info({:periodically_iter, interval_ms, acc, fun}, ctx) do
periodically_iter(interval_ms, acc, fun)
{:noreply, ctx}
end
defp periodically_iter(interval_ms, acc, fun) do
case fun.(acc) do
{:cont, acc} ->
Process.send_after(self(), {:periodically_iter, interval_ms, acc, fun}, interval_ms)
:halt ->
:ok
end
end
defp config do
default_config = [theme: :livebook]
default_config
|> Keyword.merge(Application.get_all_env(:kino_vega_lite))
|> Map.new()
end
end