Skip to content

Plot Object Redesign

jkrumbiegel edited this page Nov 18, 2020 · 4 revisions

Plot Objects

Plot Input Object

  • absolutely leightweight
  • pretty much just wraps user input
  • only contains values, no observables
  • must work well as PlotInput(observable) and Observable(PlotInput(value))
  • can be used everywhere e.g. in recipes, has no real dependencies
  • can be easily serialized as JSON
  • has just one Observable, that manages inputs/outputs
  • is purely for enabling high level API & recipes
struct PlotInput
    # name of targeted plot function
    # maybe need to be a type paramter to implement conversion functions
    # to lower level objects
    name::Symbol
    attributes::Dict{Symbol, Any}
    on_update::Observable{Pair{Symbol, Any}}
    on_update_callbacks::Dict{Symbol, Set{Function}}
    connections::Dict{Symbol, Observables.ObserverFunction}

    function PlotInput(name::Symbol, attributes::Dict{Symbol, Any})
        on_update = Observable{Pair{Symbol, Any}}()
        on_update_callbacks = Dict{Symbol, Set{Function}}()
        connections = Dict{Symbol, Observables.ObserverFunction}()
        on(on_update) do (name, value)
            # on update callbacks is for things like `on(plot, :mouseposition)`
            # but can also be used to register for attribute field updates
            if haskey(on_update_callbacks, name)
                callbacks = on_update_callbacks[name]
                for callback in callbacks
                    Base.invokelatest(callback, value)
                end
            end
            if haskey(attributes, name)
                # If it is an attribute field, we update it!
                attributes[name] = value
            end
        end

        return new(name, attributes, on_update, on_update_callbacks, connections)
    end
end

"""
Convenience for constructing PlotObject:
@plot scatter(1:3, color=10) == PlotObject(:scatter, 1:3; color=10)
"""
macro plot(expr)
    ...
end

function PlotObject(name; kw...)

end

function register_observable!(plot::PlotInput, (name, observable)::Pair{Symbol, Observable})
    func = on(observable) do value
        plot.on_update[] = name => value
    end
    # update value on first time
    attributes[name] = observable[]
    plot.connections[name] = func
    return
end

function disconnect_observable!(plot::PlotInput, (name, observable)::Pair{Symbol, Observable})
    off(plot.on_update, plot.connections[name])
    return
end

function on(f::Function, plot::PlotInput, name::Symbol)
    callbacks = get(plot.on_update_callbacks, name, Set{Function}())
    push!(callbacks, f)
    return
end

function disconnect!(plot::PlotInput)
    for (name, func) in plot.connections
        off(plot.on_update, func)
    end
    empty!(plot.on_update_callbacks)
end

"""
    flatten_plotobject(plot_observable::Observable{PlotObject})
Converts an `Observable{PlotObject}` to a `PlotObject` that updates whenever `plot_observable` updates.
"""
function flatten_plotobject(plot_observable::Observable{PlotObject})
    plot = copy(plot_observable[])
    on(plot_observable) do new_plot
        for (name, value) in new_plot
            # do some lightweight diffing
            if plot[name] != value
                plot[name] = value
            end
        end
    end
    return plot
end

Internal Plot object representation

  • concretly typed
  • fully converted
  • directly digestable by backends
  • well defined API for backends
  • a recipe doesn't do anything but convert some type to a number of internal plot objects
  • conversion to backend plot object is done optionally by backend, so a backend can overload plot objects at any level
  • objects should be as simple and non magical as possible
  • conversion/recipe pipeline should look something like this:
    while !is_supported(backend, plotobject)
        plotobject = apply_recipe(plotobject)
    end
    draw_object(backend, plotobject)

Example in code:

struct Image
    bounds::Tuple{Interval, Interval}
    data::AbstractMatrix{T <: Colorant}
end

struct MeshPlot
    mesh::Mesh
end

to_sampler(image::AbstractMatrix{<: Colorant}) = image

function to_sampler(image::PlotObject)
    if haskey(image, :colormap)
        to_sampler(image.data, image.colormap)
    else
        to_sampler(image.data)
    end
end

function recipe(image::PlotObject, ::Val{:image})
    bounds = (to_interval(image.x), to_interval(image.y))
    return Image(bounds, to_sampler(image))
end

function recipe(image::Image)
    xy_min_max = extrema.(image.bounds)
    xy = first.(xy_min_max)
    widths = last.(xy_min_max) .- xy
    bound_rect = Rect(xy, widths)
    uv = texturecoordinates(bound_rect)
    positions = coordinates(bound_rect)
    color = sampler(image.data, uv)
    return Mesh(meta(positions, color=color), faces(bound_rect))
end

Questions

How are plot objects updated if they only have one observable? What kind of signal does an update generate and how is it picked up by other components?

Can multiple attributes be updated simultaneously to avoid the problem of e.g. array lengths going out of sync?

Maybe one can do it by sending updates via one observable with a Dict of named attributes?

plot.attributes = Dict(:x => randn(100), :y => randn(100))

update!(plot, :x => randn(200), :y => randn(200))

function update!(plot, symbolpairs...)
    # do something depending on which symbols were sent?
    # I'm imagining something like `lift` but where only one Dict is lifted and
    # then it's checked whether the changed symbols actually apply to this `lift`
end

# for example
symbollift(dict_observable, :x, :y, :z) do x, y, z
    # is called whenever some subset of {:x, :y, :z} is changed
end

Some plot objects need information about scene things like camera settings (for errorbar whiskers for example). How can a plot object hook into that while still being easily removable?

How can backend redraws be triggered only when necessary, i.e. when plot objects change?

How can it be avoided to rerun conversion pipeline steps when only part of a plot objects inputs change?