Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve specapi docs #3378

Merged
merged 1 commit into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
332 changes: 332 additions & 0 deletions docs/explanations/specapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
# SpecApi

!!! warning
The SpecApi is still under active development and might introduce breaking changes quickly in the future.
It's also slower for animations than using the normal Makie API, since it needs to re-create plots often and needs to go over the whole plot tree to find different values.
While the performance will always be slower than directly using Observables to update attributes, it's still not much optimized so we expect to improve it in the future.
You should also expect bugs, since the API is still very new while offering lots of new and complex functionality.
Don't hesitate to open issues if you run into unexpected behaviour.
PRs are also more than welcome, the code isn't actually that complex and should be easy to dive into (src/basic_recipes/specapi.jl).

## What is the SpecApi?

Starting with version 0.20, Makie supports the creation of plots and figures through specification or "spec" objects.
These objects are declarative versions of familiar objects like `Axis`, `Colorbar`, `Scatter` or `Heatmap`.
Declarative means that these objects do not implement any of the complex internal machinery needed for interactive, stateful plotting.
Instead, they are simply descriptions which Makie then converts into the full objects for you.

Why is this useful?

Spec objects are lightweight and easy to compose into larger structures without having to build those up step by step.
This makes it possible to glue together subfigures returned from different functions, instead of having these functions mutatingly plot into the same existing parent `Figure`.
You can also plot observable spec objects into a given `Figure` and when you change the description, the whole subfigure updates or rebuilds itself automatically, trying to preserve existing structures where possible through the use of diffing.
This can make building dashboards or smaller interactive apps much easier because the user has to keep track of less state.
On the flip side, diffing introduces a performance overhead and will in general not be as fast as using Makie's mutating API directly.

You can create spec objects with the `Makie.SpecApi` object.
There are mainly two types of specs, `PlotSpec`s and `BlockSpec`s, corresponding to plot objects like `Scatter` or `Heatmap`, and `Block` objects like `Axis` or `Colorbar`.

The API is supposed to be similar to the normal API, just declarative.
Complex specs are built by nesting simpler specs inside each other.
The convention is to always use the `S` prefix when creating spec objects, so they can't be confused with their counterparts from the standard API.

```julia
import Makie.SpecApi as S

scatterspec = S.Scatter(1:4) # a PlotSpec describing a Scatter plot
axspec = S.Axis(plots=[scatterspec]) # a BlockSpec describing an Axis with a Scatter plot
figspec = S.Figure(ax) # a FigureSpec describing a Figure with an Axis with a Scatter plot

# Now we can instantiate the spec into a fully realized Figure.
# Note that the output type from `plot` is currently, a bit confusingly, a
# FigureAxisPlot type, which does not really fit because `pl` is not a normal plot
# and there can be zero or many axes in the figure.
# This will be changed in future iterations.
f, _, pl = plot(figspec)

# By updating the input observable of `pl`, our "plot" object, we can
# update all the content in the Figure with something new. In this case,
# we just change the plot type in the Axis from Scatter to Lines, and the
# axis title to "Lines".
pl[1] = S.Figure(S.Axis(; title="Lines", plots=[S.Lines(1:4)]))
```

You can not only `plot` specs describing whole `Figure`s, but also specs describing `Block`s or just single plots.

```julia
s = Makie.PlotSpec(:Scatter, 1:4; color=:red)
axis = Makie.BlockSpec(:Axis; title="Axis at layout position (1, 1)")
```

## Building layouts for specs

To build layouts quickly, you can pass column vectors, row vectors or matrices of block specs to `S.Figure`. If you need more control over the layout, you can use `S.GridLayout` to specify row and column sizes and gaps directly.

\begin{examplefigure}{}
```julia
using GLMakie
using DelimitedFiles
using Makie.FileIO
import Makie.SpecApi as S
using Random
GLMakie.activate!(inline = true) # hide

Random.seed!(123)

volcano = readdlm(Makie.assetpath("volcano.csv"), ',', Float64)
brain = load(assetpath("brain.stl"))
r = LinRange(-1, 1, 100)
cube = [(x .^ 2 + y .^ 2 + z .^ 2) for x = r, y = r, z = r]

density_plots = map(x -> S.Density(x * randn(200) .+ 3x, color=:y), 1:5)
brain_mesh = S.Mesh(brain, colormap=:Spectral, color=[tri[1][2] for tri in brain for i in 1:3])
volcano_contour = S.Contourf(volcano; colormap=:inferno)
cube_contour = S.Contour(cube, alpha=0.5)

ax_densities = S.Axis(; plots=density_plots, yautolimitmargin = (0, 0.1))
ax_volcano = S.Axis(; plots=[volcano_contour])
ax_brain = S.Axis3(; plots=[brain_mesh], protrusions = (50, 20, 10, 0))
ax_cube = S.Axis3(; plots=[cube_contour], protrusions = (50, 20, 10, 0))

spec_column_vector = S.GridLayout([ax_densities, ax_volcano, ax_brain]);
spec_matrix = S.GridLayout([ax_densities ax_volcano; ax_brain ax_cube]);
spec_row = S.GridLayout([spec_column_vector spec_matrix], colsizes = [Auto(), Auto(4)])

plot(S.Figure(spec_row); figure = (; fontsize = 10))
```
\end{examplefigure}

## Advanced spec layouting

If you need even more control, you can pass the position of each object in your layout to `S.GridLayout` directly.
These positions are specified as a tuple of `(rows, columns [, side])` where `side` is `Inside()` by default.
For `rows` and `columns` you can either use integers like `2`, ranges like `1:3` or the colon operator `:` which spans across all rows or columns that are specified for other elements.
Rows and columns start at `1` by default but you can also use numbers lower than `1` if necessary.

\begin{examplefigure}{svg = true}
```julia
using CairoMakie
import Makie.SpecApi as S
Makie.inline!(true) # hide
CairoMakie.activate!() # hide

plot(
S.Figure(S.GridLayout([
(1, 1) => S.Axis(),
(1, 2) => S.Axis(),
(2, :) => S.Axis(),
(2, 2, Right()) => S.Box(),
(2, 2, Right()) => S.Label(
text = "Label",
rotation = pi/2,
padding = (10, 10, 10, 10)
),
]))
)
```
\end{examplefigure}

You can also use manual positions with nested `GridLayout`s.

\begin{examplefigure}{}
```julia
using CairoMakie
import Makie.SpecApi as S
Makie.inline!(true) # hide
CairoMakie.activate!() # hide

plot(S.Figure(S.GridLayout([
(1, 1) => S.Axis(),
(1, 2) => S.Axis(),
(2, :) => S.GridLayout(fill(S.Axis(), 1, 3)),
])))
```
\end{examplefigure}

Here are all the keyword arguments that `S.GridLayout` accepts.

```julia
S.GridLayout([...],
colsizes = [Auto(), Auto(), 300],
rowsizes = [Relative(0.4), Relative(0.6)],
colgaps,
rowgaps,
alignmode,
halign,
valign,
tellheight,
tellwidth,
)
```

## Using specs in `convert_arguments`

!!! warning
It's not decided yet how to forward keyword arguments from `plots(...; kw...)` to `convert_arguments` for the SpecApi in a more convenient and performant way. Until then, you need to mark attributes you want to use in `convert_arguments` with `Makie.used_attributes`, but this will completely redraw the entire spec on change of any attribute.

You can overload `convert_arguments` and return an array of `PlotSpecs` or a `FigureSpec`.
The main difference between those is, that returning an array of `PlotSpecs` may be plotted like any recipe into axes, while overloads returning `FigureSpec` may not.

## `convert_arguments` for `FigureSpec`

In this example, we overload `convert_arguments` for a custom type to create facet grids easily.

\begin{examplefigure}{svg = true}
```julia
using CairoMakie
import Makie.SpecApi as S
CairoMakie.activate!() # hide

# Our custom type we want to write a conversion method for
struct PlotGrid
nplots::Tuple{Int,Int}
end

# If we want to use the `color` attribute in the conversion, we have to
# mark it via `used_attributes`
Makie.used_attributes(::Type{<:AbstractPlot}, ::PlotGrid) = (:color,)

# The conversion method creates a grid of `Axis` objects with `Lines` plot inside
function Makie.convert_arguments(::Type{<:AbstractPlot}, obj::PlotGrid; color=:black)
axes = [
S.Axis(plots=[S.Lines(cumsum(randn(1000)); color=color)])
for i in 1:obj.nplots[1],
j in 1:obj.nplots[2]
]
return S.Figure(axes; fontsize=30)
end

# Now, when we plot `PlotGrid` we get a whole facet layout
plot(PlotGrid((3, 4)))
```
\end{examplefigure}

We can also plot into existing `Figure`s with our new `plot` method:

\begin{examplefigure}{svg = true}
```julia
f = Figure()
plot(f[1, 1], PlotGrid((2, 2)); color=Cycled(1))
plot(f[1, 2], PlotGrid((3, 2)); color=Cycled(2))
f
```
\end{examplefigure}

## `convert_arguments` for `PlotSpec`s

We can return a vector of `PlotSpec`s from `convert_arguments` which allows us to dynamically choose the plot objects we want to add given the input data.
While you could choose plot types based on input data with the old recipe API as well, this did not easily work for observable updates that changed these plot types in an existing figure.
For this, users had to do tedious manual bookkeeping which is now abstracted away.

Note, that this method currently doesn't allow to forward keyword arguments from the `plot` command to `convert_arguments`, so we put the plot arguments into the `LineScatter` object in the following example:

\begin{examplefigure}{}
```julia
using CairoMakie
import Makie.SpecApi as S
using Random
CairoMakie.activate!() # hide

Random.seed!(123)

# define a struct for `convert_arguments`
struct CustomMatrix
data::Matrix{Float32}
style::Symbol
kw::Dict{Symbol,Any}
end
CustomMatrix(data; style, kw...) = CustomMatrix(data, style, Dict{Symbol,Any}(kw))

function Makie.convert_arguments(::Type{<:AbstractPlot}, obj::CustomMatrix)
plots = PlotSpec[]
if obj.style === :heatmap
push!(plots, S.Heatmap(obj.data; obj.kw...))
elseif obj.style === :contourf
push!(plots, S.Contourf(obj.data; obj.kw...))
end
max_position = Tuple(argmax(obj.data))
push!(plots, S.Scatter(max_position; markersize = 30, strokecolor = :white, color = :transparent, strokewidth = 4))
return plots
end

data = randn(30, 30)

f = Figure()
ax = Axis(f[1, 1])
# We can either plot into an existing Axis
plot!(ax, CustomMatrix(data, style = :heatmap, colormap = :Blues))
# Or create a new one automatically as we are used to from the standard API
plot(f[1, 2], CustomMatrix(data, style = :contourf, colormap = :inferno))
f
```
\end{examplefigure}


## Interactive example

The SpecApi is geared towards dashboards and interactively creating complex plots.
Here is an example using a `Slider` and a `Menu`, to visualize a fake simulation:

~~~
<input id="hidecode" class="hidecode" type="checkbox">
~~~
```julia:simulation
using GLMakie
import Makie.SpecApi as S
GLMakie.activate!() # hide
struct MySimulation
plottype::Symbol
arguments::AbstractVector
end
function Makie.convert_arguments(::Type{<:AbstractPlot}, sim::MySimulation)
return map(enumerate(sim.arguments)) do (i, data)
return PlotSpec(sim.plottype, data)
end
end
f = Figure()
s = Slider(f[1, 1], range=1:10)
m = Menu(f[1, 2], options=[:Scatter, :Lines, :BarPlot])
sim = lift(s.value, m.selection) do n_plots, p
args = [cumsum(randn(100)) for i in 1:n_plots]
return MySimulation(p, args)
end
ax, pl = plot(f[2, :], sim)
tight_ticklabel_spacing!(ax)
# lower priority to make sure the call back is always called last
on(sim; priority=-1) do x
autolimits!(ax)
end
record(f, "interactive_specapi.mp4", framerate=1) do io
pause = 0.1
m.i_selected[] = 1
for i in 1:4
set_close_to!(s, i)
sleep(pause)
recordframe!(io)
end
m.i_selected[] = 2
sleep(pause)
recordframe!(io)
for i in 5:7
set_close_to!(s, i)
sleep(pause)
recordframe!(io)
end
m.i_selected[] = 3
sleep(pause)
recordframe!(io)
for i in 7:10
set_close_to!(s, i)
sleep(pause)
recordframe!(io)
end
end
```
~~~
<label for="hidecode" class="hidecode"></label>
~~~

\video{interactive_specapi, autoplay = true}
Loading