bmitc

bmitc

Looking for best way to animate a VegaLite chart in Livebook

I am trying to animate VegaLite charts in Livebook. The idea that I am currently working towards is to animate a 2D histogram over time. I have seen some work (external to Elixir) to specify animations in VegaLite, but what I am wanting is to simply create a VegaLite chart every frame, so I am not looking for super high framerates. Livebook to replicate the issues below. (Note that you need to install Node.js and some packages for exporting VegaLite charts. See here.)

Let’s use this code as example:

alias VegaLite, as: Vl

chart = fn ->
  data =
    for month <- ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] do
      for day <- 1..31 do
        %{month: month, day: day, temperature: Enum.random(32..100)}
      end
    end
    |> List.flatten()

  Vl.new(title: "Daily max temperatures")
  |> Vl.data_from_values(data)
  |> Vl.mark(:rect)
  |> Vl.encode_field(:x, "day", type: :ordinal, title: "Day")
  |> Vl.encode_field(:y, "month", type: :ordinal, title: "Month")
  |> Vl.encode_field(:color, "temperature",
    aggregate: :max,
    type: :quantitative,
    legend: [title: nil]
  )
end

Inside a Livebook cell, I can animate it like this:

Stream.interval(500)
|> Stream.take(10)
|> Kino.animate(nil, fn _, _ ->
  display = chart.()
  {:cont, display, nil}
end)

This works and is fast for animation purposes, but this has highly undesirable blinking and flickering since the chart completely disappears between each frame.

animation1

This code exports the chart to SVG and then creates a Kino image:

Stream.interval(100)
|> Stream.take(100)
|> Kino.animate(nil, fn _, _ ->
  display =
    chart.()
    |> Vl.Export.to_svg()
    |> Kino.Image.new(:svg)

  {:cont, display, nil}
end)

This works as desired to create a smooth animation such that between each frame there is no flickering or resetting of the image, but it is extremely slow. VegaLite.Export.to_svg/1 takes over 1.7 seconds on average. Looking at the implementation, it looks like it has a file write, which may be one reason why it is so slow.

animation2

How can I create the behavior of the second example with the speed of the first?

Marked As Solved

awerment

awerment

Caveat: as I’m definitely not an expert, there might be a better way to do it. Just got curious reading your question and decided to look into it.

Solution I got to work (You’ll need to add the kino_vega_lite dependency for this):

Chart & Data functions

alias VegaLite, as: Vl

chart = fn ->
  Vl.new(title: "Daily max temperatures")
  |> Vl.mark(:rect)
  |> Vl.encode_field(:x, "day", type: :ordinal, title: "Day")
  |> Vl.encode_field(:y, "month", type: :ordinal, title: "Month")
  |> Vl.encode_field(:color, "temperature",
    aggregate: :max,
    type: :quantitative,
    legend: [title: nil]
  )
end

data = fn ->
  for month <- ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] do
    for day <- 1..31 do
      %{month: month, day: day, temperature: Enum.random(32..100)}
    end
  end
  |> List.flatten()
end

Render & update

update = fn chart ->
  data = data.()
  window = Enum.count(data)
  Kino.VegaLite.push_many(chart, data, window: window)
end

chart = chart.() |> Kino.VegaLite.new() |> Kino.render()

# you could use your approach with `Stream.interval/1` here as well
Kino.VegaLite.periodically(chart, 100, nil, fn _ ->
  update.(chart)
  {:cont, nil}
end)

Basically, chart definition and data generation are split and the data points are pushed to the chart.

As push_many only adds new data points to the chart, we have to make sure that old ones are removed from the dataset - that’s what calculating and setting the :window is supposed to achieve.

Also Liked

bmitc

bmitc

Thanks for taking another look! For now, I am using Kino.animate/3 and returning the iterations for it to render, while the loop calls Kino.VegaLite.push_many/3. Something like this:

    Stream.interval(interval_ms)
    |> Stream.take(iterations)
    |> Kino.animate(environment, fn i, env ->
      update.(chart, env)

      <some stuff>

      if stop do
        :halt
      else
        {:cont, "Iteration: #{i + 1}", tick(env)}
      end
    end)

Here’s a video of the animation I got going:

I feel like the first technique should really be working, since it is supposed to be updating a Kino.Frame (my understanding), but I’m not sure why the frame seems to go away between updates.

However, the technique you provided is at least working for my needs currently. Thank you for your help on this! I’m super happy about this.

awerment

awerment

I should also mention that you could use VegaLite.param/3 to bind to input elements; that functionality is built in if you just need controls to modify the chart based on user input.

The proposed Kino.VegaLite.signal/3 function allows updating the values from Elixir code, though. The binding of signal<->control seems to be two-way, so it works as you’d expect.

alias VegaLite, as: Vl

default_stroke_width = 5

chart_spec = fn ->
  Vl.new(width: 400, height: 400)
  |> Vl.param("strokeWidth",
    value: default_stroke_width,
    # bind the signal to a range input, rendered by vega
    bind: [input: "range", min: 1, max: 10, step: 1, name: "Stroke Width"]
  )
  |> Vl.mark(:line, stroke_width: [signal: "strokeWidth"])
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative)
end

data = fn ->
  for i <- 1..150, do: %{x: i / 10, y: :math.sin(i / 10)}
end

update = fn chart ->
  data = data.()
  window = Enum.count(data)
  Kino.VegaLite.push_many(chart, data, window: window)
  chart
end

chart = chart_spec.() |> Kino.VegaLite.new() |> update.()
Kino.render(chart)

Kino.Control.button("Reset Stroke Width")
|> Kino.render()
|> Kino.listen(fn _event ->
  Kino.VegaLite.signal(chart, "strokeWidth", default_stroke_width)
end)

awerment

awerment

Sorry for spamming this thread so much, but wanted to leave some additional info for anyone stumbling into [expr: "param_name"] not working for certain chart properties.

You’ll need to consult the vega-lite docs, which properties accept an ExprRef, see for example the title spec. So, a correction for the above: you can dynamically update the chart title by setting it to a param like so:

VegaLite.new(title: [text: [expr: "title_param"]])
|> VegaLite.param("title_param", value: "Default Title")

and updating it later via Kino.VegaLite with

Kino.VegaLite.set_param(chart, "title_param", "Updated Title")

On the other hand, looking at the encoding field definition spec, we see that its title property only accepts Text | Null, which means this will not work:

VegaLite.new(title: "Static Chart Title")
|> VegaLite.param("x_title", value: "Default X-Axis Label")
|> VegaLite.mark(:line)
|> VegaLite.encode_field(:x, "x", type: :quantitative, title: [expr: "x_title"]) # does not accept an 'ExprRef'
|> VegaLite.encode_field(:y, "y", type: :quantitative, title: "Static Y-Axis Label")

Now, as mentioned above, signals are not documented or officially supported by vega-lite (see here and here). That being said… if [expr: "param_name"] does not work, you could try [signal: "param_name"] instead. It does work for the field title, for example:

VegaLite.new(title: "Static Chart Title")
|> VegaLite.param("x_title", value: "Default X-Axis Label")
|> VegaLite.mark(:line)
|> VegaLite.encode_field(:x, "x", type: :quantitative, title: [signal: "x_title"])
|> VegaLite.encode_field(:y, "y", type: :quantitative, title: "Static Y-Axis Label")

Updating it via Kino.VegaLite.set_param/3 works the same.

Just be aware that we’re using a non-public API, strictly speaking.

bmitc

bmitc

Despite your caveat, this works great! I had seen push and push_many, but I had neglected to look into them more, and the :window option works great here. I had to update it a bit for my actual use case, which uses VegaLite.layers to layer multiple histograms. That mainly required just using the :dataset option for Kino.VegaLite.push_many and writing the :name parameter in my datasets (not the top level chart), for example using VegaLite.data(<chart>, name: <name>).

For this new method, I’m not sure how to update any other bits of the chart aside from the data. For example, I’d like to animate the chart title along with the data, maybe displaying the iteration count, or change other pieces of the chart. With Kino.VegaLite.push_many, I think I’m only able to update the actual data, as far as I can tell.

awerment

awerment

Glad it helped take the first hurdle!

Looking a bit more into the kino_vega_lite code, it doesn’t look like updating other fields besides the datasets of the passed VegaLite struct is supported; it’s exported as a spec and passed on to the underlying JS code, which also handles only data updates.

So it’s back to square one as far as re-rendering the charts without the flicker goes. :confused: One (arguably quite inelegant) idea would be to pre-render the next iteration and swap it out with the current, but I’m not sure that’s even possible with Kino.

Where Next?

Popular in Questions Top

srinivasu
How to handle excepions in elixir? Suppose i have A, B, C ,D, E modules. and each module has get() function. A.get() method will call th...
New
Werner
Hi, I’m using Ubuntu 18.04 and after updating to OTP-24.0 yesterday i have this warning when I run “mix local.hex”: 14:57:30.512 [warn] ...
New
vac
Hi, I'm quite new in Elixir and I'm trying to format a string to a PEM format. I have the certificate value like MIIDBTCCAe2...... and ...
New
gonzofish
I’m currently trying to understand how to join three tables using Ecto. All the examples I’ve seen use 2, so maybe I’m just missing somet...
New
ovidiubadita
Hey all, I discovered Elixir and I love it. I always wanted to learn a functional programming and I intended to go for Haskell, but afte...
New
Exadra37
Sometimes I want to check if the input into a function is not a blank string. My first approach: defmodule Example do def do_stuff(s...
New
wernerlaude
In AR this is so simple @articles = current_user.articles How to do in Ecto? def index(conn, _params) do current_user = conn.assig...
New
skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New
lucidguppy
I have a super simple question about elixir - how would I take a file like this foo bar baz and output a new file that enumerates th...
New
jay1
Why is it that the mnesia database isn’t the most preferred database for use in Elixir/Phoenix?
New

Other popular topics Top

TunkShif
This post is an instruction guide to help you setup your Neovim for Elixir development from scratch. It includes general information on h...
273 38985 115
New
Patoshizzle
After calling mix ecto.create I get this error: 17:00:32.162 [error] GenServer #PID&lt;0.412.0&gt; terminating ** (Postgrex.Error) FATAL...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
script
If I have a string “1000 cfu/ml” . I want to remove the characters and / and space . So the string is like this "1000" What is the ...
New
chrismccord
As promised, the first release candidate of Phoenix 1.3.0 is out! This release focuses on code generators with improved project structure...
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 42633 214
New
AstonJ
We’ve put together this wiki for Phoenix LiveView - please feel free to add any info you feel is worth including. What is Phoenix LiveV...
New

We're in Beta

About us Mission Statement