Plotting with Observables in Makie

I want to make an interactive plot with sliders, where the displayed data changes when my sliders get moved. That seems to work when the observable plotted is an array of positions.
But is it possible if the observable is something else, say a dataframe?

For example, this works nicely (adapted from an old Makie issue post):

using GLMakie
fig = Figure()
ax = Axis(fig[1,1:2])
s1 = Slider(fig[2,1], range = 0.1:0.1:10, startvalue = 3)
s2 = Slider(fig[2,2], range = LinRange(-2pi, 2pi, 100), startvalue = 0.0)
data = lift(s2.value) do v
    map(LinRange(0, 2pi, 100)) do x
        4f0 .* Point2f0(sin(x) + (sin(x * v) .* 0.1), cos(x) + (cos(x * v) .* 0.1))
    end
end
p = scatter!(fig[1,1:2], data, markersize = s1.value)

But what would I do if the data Observable is not a nice positions array? Then updates don’t seem to work right:

data = lift(s2.value) do v
    xs = [4f0 .* (sin(x) + (sin(x * v) .* 0.1)) for x in LinRange(0, 2pi, 100)]
    ys = [4f0 .* (cos(x) + (cos(x * v) .* 0.1)) for x in LinRange(0, 2pi, 100)]
    ys2 = [4f0 .* (cos(x) + (cos(x * v) .* 0.3)) for x in LinRange(0, 2pi, 100)]
    [xs, ys, ys2]
end
p = scatter!(fig[1,1:2], data[][1], data[][2], markersize = s1.value)

There the first slider still changes the point size, and the second slider still changes the underlying data, but it does not get replotted. What could I do here?

I tried using

p = lift(data) do d
    scatter!(d[1], d[2], markersize = s1.value)
end

but that adds entirely new plots to the graph, i.e. just overlays new points into the plot, whenever the slider is moved.

What’s the best way to get a plot that updates correctly, if say the Observable is an array, or maybe even a DataFrame?

The only way I could think about doing this is creating a new separate observable (linked to the data observable) that converts each data part I want to plot into a positions array.

data = lift(s2.value) do v
    xs = [4f0 .* (sin(x) + (sin(x * v) .* 0.1)) for x in LinRange(0, 2pi, 100)]
    ys = [4f0 .* (cos(x) + (cos(x * v) .* 0.1)) for x in LinRange(0, 2pi, 100)]
    ys2 = [4f0 .* (cos(x) + (cos(x * v) .* 0.3)) for x in LinRange(0, 2pi, 100)]
    [xs, ys, ys2]
end
data1 = lift(data) do v
    map(i -> Point2f0(data[][1][i], data[][2][i]), 1:1:length(data[][1]))
end
data2 = lift(data) do v
    map(i -> Point2f0(data[][1][i], data[][3][i]), 1:1:length(data[][1]))
end
p = scatter!(fig[1,1:2], data1, markersize = s1.value)
p = scatter!(fig[1,1:2], data2, markersize = s1.value)

That works but seems very kludgy. And the selection of which columns to plot is hard coded.
Is this really the only way to do this? Or is there a way to say just pass say the first and second column of the data array to scatter!(), and still have it update with a data change?

Side question, is there a way to autoscale the y axis to the data while the slider is being moved?

When you do this, you disable the observable functionality because you grab the plain array from inside the observable. Plots can only update automatically if they are passed observables.

If you have a dataframe that should send updates to a plot, you could do it like this: Put the dataframe in one observable. Make, e.g., two observables with Symbols that decide which columns to plot. Then create a vector of points by lifting those three observables:

df_obs = Observable(DataFrame(x = [1, 2, 3], y = [4, 5, 6], z = [7, 8, 9])
col_1 = Observable(:x)
col_2 = Observable(:y)

data = @lift(Point2f.($df_obs[:, $col_1], $df_obs[:, $col_2]))

scatter(data)

Now you can plot x against z instead by doing col_2[] = :z. Or you mutate your dataframe somehow, but this will not directly trigger the Observable. An observable is only triggered if you replace its content with the obs[] = x syntax (but you don’t have a new dataframe). So if you do something inside the dataframe and want to update the plot, you just do notify(df_obs) instead.

3 Likes

Those are great suggestions, thanks for the ideas! That’s exactly what I was looking for.

It would be nice if scatter!() had some sort of simpler syntax that would work for dataframe observable columns, or in general for quantities derived from observables.

Maybe something along the lines of plot routines having a method accepting a {observable, quantity derived from observable} pair as x, y or z data. If the observable changes, it would update the plot using the derived quantity. E.g. passing [df_obs, df_obs.col_1] .

That’s kind of what the @lift macro is for, you could also plot scatter(@lift($df_obs.some_column)) I think

1 Like

As a complement for other readers, the relevant section of the manual is Observables & Interaction.

Thanks again! That’s what I was trying to do originally, but I couldn’t find the magic incantation for getting the dataframe column from the observable inside @lift.
Using your example line gives me:

fig = Figure()
ax = Axis(fig[1,1])
df_obs = Observable(DataFrame(x = [1, 2, 3], y = [4, 5, 6]))
scatter!(@lift($df_obs.x), @lift($df_obs.y))

which works nicely with scatter(x, y), and the plot updates when the data is modified.
(e.g. df_obs[].y[1]=5 and then notify(df_obs) or df_obs[]=df_obs[])

I think I get it now!

It was the syntax that was pretty confusing to me: In the REPL, df_obs.x gives an error (Field x not found), which makes sense since df_obs is an observable not a dataframe. So I must use df_obs[].x to get the dataframe column.
But inside the @lift macro, using $df_obs.x works! I didn’t expect that. I guess it’s not interpreted as $(df_obs.x), which would be an error, but as ($df_obs).x, where $df_obs gives me what’s contained inside the observable - without using [] - and not the observable itself.

1 Like

In the macro you mark all expressions that should evaluate to an observable with $ and then you don’t have to specifically extract their value as well, just like you work with the extracted values inside the lift function itself.

1 Like