Skip to content
Snippets Groups Projects
plots.jl 19.9 KiB
Newer Older
François  LAURENT's avatar
François LAURENT committed

theme = Dict(:LarvaPlot => Dict(
                                :path_color => :lightgrey,
                                :path_linewidth => 1,
                                :shape_linewidth => 2,
                                :shape_alpha => 0.1f0,
                                :fallback_color => :lightgrey,
                               ))

@recipe(LarvaPlot) do scene
    Theme(theme[:LarvaPlot]...)
François  LAURENT's avatar
François LAURENT committed
end

#

const CompatLarvaID = Int16
const ActiveLarva = Union{Nothing, CompatLarvaID}

François  LAURENT's avatar
François LAURENT committed
function withalpha(outline_color, alpha_value)
    color = to_color(outline_color)
    return RGBA(color.r, color.g, color.b, alpha_value)
end

withalpha(outline_color::Observable, alpha_value::Observable) = lift(withalpha, outline_color, alpha_value)

const MinimalLarva = Tuple{PathOrOutline, PathOrOutline, Color, Bool}

const wgl = startswith(string(Makie.current_backend[]), "WGL")

function Makie.plot!(plot::LarvaPlot{<:MinimalLarva})
    path = plot[1]
    shape_outline = plot[2]
    shape_color = plot[3]
    visibility = plot[4]
    lines!(plot, path;
           color=plot.path_color,
           linewidth=plot.path_linewidth,
François  LAURENT's avatar
François LAURENT committed
           visible=visibility)
    lines!(plot, shape_outline;
           color=shape_color,
           linewidth=plot.shape_linewidth,
François  LAURENT's avatar
François LAURENT committed
           visible=visibility)
    translate!(plot.plots[end], Makie.Vec3f(0, 0, 1))
    if !wgl
        poly!(plot, shape_outline;
              color=withalpha(shape_color, plot.shape_alpha),
              transparency=true,
              visible=visibility)
        translate!(plot.plots[end], Makie.Vec3f(0, 0, 0.5))
    end
François  LAURENT's avatar
François LAURENT committed
end

"""
Larva controller.
"""
struct StatefulLarva
    # model side
    model::LarvaModel
    tag_lut::AbstractObservable{<:TagLUT}
    timestep::AbstractObservable{Int}
    visible::AbstractObservable{Bool}
    # view side
    shape_outline::AbstractObservable{PathOrOutline}
    shape_color::AbstractObservable{<:Color}
end
François  LAURENT's avatar
François LAURENT committed

function StatefulLarva(larva::LarvaModel,
        tag_lut::AbstractObservable{<:TagLUT},
        timestep::AbstractObservable{Int};
        fallback_color::OptionalColor=nothing,
    )
    timeseries = larva.fullstates
François  LAURENT's avatar
François LAURENT committed
    alignedsteps = larva.alignedsteps
    first_timestep = alignedsteps[1]
    last_timestep = alignedsteps[end]

    fallback_color = @something fallback_color theme[:LarvaPlot][:fallback_color]
François  LAURENT's avatar
François LAURENT committed

    # initial state
    _, state = timeseries[1]
François  LAURENT's avatar
François LAURENT committed
    _, color = gettag(tag_lut, larva, larva.alignedsteps[1], fallback_color)
François  LAURENT's avatar
François LAURENT committed

    # key observables
    shape_outline = Observable(outline(Makie.Point2f, state))
François  LAURENT's avatar
François LAURENT committed
    shape_color = Observable(html_color(color))
François  LAURENT's avatar
François LAURENT committed
    visibility = Observable(false)

François  LAURENT's avatar
François LAURENT committed
    onany(timestep, tag_lut, larva.usertags) do timestep, tag_lut, _
François  LAURENT's avatar
François LAURENT committed
        if first_timestep<=timestep && timestep<=last_timestep
            step = timestep - first_timestep + 1
            _, state = timeseries[step]
            shape_outline.val = outline(Makie.Point2f, state)
François  LAURENT's avatar
François LAURENT committed
            #
            _, color = gettag(tag_lut, larva, timestep, fallback_color)
François  LAURENT's avatar
François LAURENT committed
            shape_color.val = html_color(color)
François  LAURENT's avatar
François LAURENT committed
            #
François  LAURENT's avatar
François LAURENT committed
            visibility[] = true # notify all subplots
        elseif visibility[]
            visibility[] = false
        end
    end

    StatefulLarva(larva, tag_lut, timestep, visibility, shape_outline, shape_color)
end
François  LAURENT's avatar
François LAURENT committed

function Makie.plot!(plot::LarvaPlot{Tuple{StatefulLarva}})
    larva = plot[1][]
    path = larva.model.path
    shape_outline = larva.shape_outline
    shape_color = larva.shape_color
    visibility = larva.visible
François  LAURENT's avatar
François LAURENT committed
    larvaplot!(plot, path, shape_outline, shape_color, visibility)
end

struct SingleLarvaView
    model::Observable
    usertags::Observable
    visible::Observable{Bool}
    path::Observable{PathOrOutline}
    outline::Observable{PathOrOutline}
    outline_color::Observable{String}
    segment::Union{Nothing, Observable{PathOrOutline}}
    segment_color::Observable{String}
    segment_visible::Observable{Bool}
François  LAURENT's avatar
François LAURENT committed

function SingleLarvaView(larvae::Vector{LarvaModel}, controller; editabletags::Bool=true)
    fallback_color = theme[:LarvaPlot][:fallback_color]
François  LAURENT's avatar
François LAURENT committed

    # initial values
    larva = larvae[1]
    _, state = larva.fullstates[1]
François  LAURENT's avatar
François LAURENT committed
    _, color = gettag(gettags(controller), larva, larva.alignedsteps[1], fallback_color)
François  LAURENT's avatar
François LAURENT committed

    # observables
    model = Observable(larva)
    usertags = Observable(larva.usertags)
    visible = Observable(false)
    path = Observable(larva.path)
    shape_outline = Observable(outline(Makie.Point2f, state))
    shape_color = Observable(html_color(color))

    # callbacks
François  LAURENT's avatar
François LAURENT committed
    on(async_latest(getactivelarva(controller))) do id
        if isnothing(id)
            visible[] = false
        else
            for larva in larvae
                if id == larva.id
                    model[] = larva
                    break
                end
            end
        end
    end
François  LAURENT's avatar
François LAURENT committed

François  LAURENT's avatar
François LAURENT committed
    tags = gettags(controller)
    timestep = gettimestep(controller)

    f = on(usertags[]) do _
        _, color = gettag(tags[], model[], timestep[], fallback_color)
        shape_color[] = html_color(color)
    end

    on(model) do larva
        path[] = larva.path
        usertags[] = larva.usertags
François  LAURENT's avatar
François LAURENT committed
        #
        Observables.off(f)
        f = on(larva.usertags) do _
            _, color = gettag(tags[], larva, timestep[], fallback_color)
            shape_color[] = html_color(color)
        end
François  LAURENT's avatar
François LAURENT committed

    onany(model,
François  LAURENT's avatar
François LAURENT committed
          timestep,
          tags,
          usertags,
         ) do larva, timestep, tag_lut, usertags
        first_timestep = larva.alignedsteps[1]
        last_timestep = larva.alignedsteps[end]
        if (first_timestep<=timestep &&
            timestep<=last_timestep &&
            getactivelarva(controller)[] == model[].id
           )
François  LAURENT's avatar
François LAURENT committed
            step = timestep - first_timestep + 1
            _, state = larva.fullstates[step]
            shape_outline.val = outline(Makie.Point2f, state)
François  LAURENT's avatar
François LAURENT committed
            #
            _, color = gettag(tag_lut, larva, timestep, fallback_color)
            shape_color.val = html_color(color)
François  LAURENT's avatar
François LAURENT committed
            #
            visible[] = true # notify all subplots
        elseif visible[]
            visible[] = false
        end
    end
François  LAURENT's avatar
François LAURENT committed

    segment = editabletags ? Observable(PathOrOutline()) : nothing
    segment_color = Observable(shape_color[])
    segment_visible = Observable(false)

    SingleLarvaView(controller, model, usertags, visible,
                    path, shape_outline, shape_color,
                    segment, segment_color, segment_visible)
François  LAURENT's avatar
François LAURENT committed

iseditable(larvaview::SingleLarvaView) = !isnothing(larvaview.segment)

norm2(p, q) = @. norm2(p - q)
function pick_timestep(scene, larva)
    pos = mouseposition(scene)
    dist2 = norm2(larva.path[], pos)
    best = argmin(dist2)
    timestep = larva.model[].alignedsteps[best]
    return timestep, best, dist2[best]
function setinitialstep!(initialstep, larva)
    step = initialstep[]
    if step == 0
        timestep = gettimestep(larva.controller)[]
        firststep = larva.model[].alignedsteps[1]
        if timestep < firststep
            @warn "Current time step is earlier than current track"
            timestep = firststep
        end
        # faster but possibly wrong:
        step = timestep - firststep + 1 #findfirst(larva.model[].alignedsteps == timestep)
        initialstep[] = step
    end
    return step
end

function pick_timesegment(scene, larva, initialstep)
    timestep, best, dist2 = pick_timestep(scene, larva)
    path = larva.path[]
    if initialstep isa Ref
        initialstep = initialstep[]
    end
    segment = initialstep <= best ? path[initialstep:best] : path[best:initialstep]
    return timestep, segment, dist2
end

function assign_tag_to_segment!(larvaview, firststep)
    iseditable(larvaview) || return
    segment_color = larvaview.segment_color[]
    controller = larvaview.controller
    larva = larvaview.model[]
    firststep′ = larva.alignedsteps[firststep]
    laststep′ = gettimestep(controller)[]
    laststep = laststep′ - larva.alignedsteps[1] + 1
    firststep″, laststep″ = min(firststep, laststep), max(firststep, laststep)
    # history
    previous_usertags = freezetags(larva.usertags[],
                                   larva.alignedsteps[firststep″],
                                   larva.alignedsteps[laststep″])
François  LAURENT's avatar
François LAURENT committed
    transaction = nothing
François  LAURENT's avatar
François LAURENT committed
    lut = gettags(controller)
    usertags = getusertags!(larva, firststep′; lut=lut)
    tag = gettag(usertags, lut)
    if isnothing(tag)
        @info "Removing all tags on segment" t0=larva.fullstates[firststep][1] t1=larva.fullstates[laststep][1]
        # history
        transaction = RemoveAllTags(larva.id,
                                    (firststep′, laststep′),
                                    previous_usertags)
        #
        for step in firststep″:laststep″
            usertags = getusertags!(larva, larva.alignedsteps[step]; lut=lut)
            deletetags!(usertags)
        end
    else
        @assert segment_color == tag.color[]
        @info "Assigning tag to segment" tag=tag.name[] t0=larva.fullstates[firststep][1] t1=larva.fullstates[laststep][1]
        # history
        transaction = OverrideTags(larva.id,
                                   (firststep′, laststep′),
                                   tag.name[],
                                   previous_usertags)
        #
        for step in firststep″:laststep″
            usertags = getusertags!(larva, larva.alignedsteps[step]; lut=lut)
            settag!(usertags, tag; single=true)
François  LAURENT's avatar
François LAURENT committed
        end
    push!(transactions(controller), transaction)
    Observables.notify(larva.usertags)
    Observables.notify(larvaview.usertags)
    Observables.notify(gettimestep(controller)) # full refresh
    flag_active_larva_as_edited(controller)
end

function setmouseevents!(scene, larva::SingleLarvaView; blocking=true, priority=Int8(10), kwargs...)
    # WGLMakie does not support addmouseevents! with plots as additional input args
    mouseevents = Makie.addmouseevents!(scene; priority=priority)

    Makie.onmouseleftclick(mouseevents) do _
        timestep, _, _ = pick_timestep(scene, larva)
        settimestep!(larva.controller, timestep)
        return Consume(false)
    end

François  LAURENT's avatar
François LAURENT committed
    reference_larva_size = getmedianlarvasize(larva.controller)

    # dead code
    modifier_active() = ispressed(scene, Keyboard.left_shift)

    function larva_hovered()
        currentstep = gettimestep(larva.controller)[]
        firststep = larva.model[].alignedsteps[1]
        centroid = larva.path[][currentstep-firststep+1]
        pointer = mouseposition(scene)
        return norm2(centroid - pointer) <= reference_larva_size^2

    dragging = Ref(false)
    initialstep = Ref(0)
        dragging[] = true
        setinitialstep!(initialstep, larva)
        if iseditable(larva)
            larva.segment_visible[] = false
            larva.segment[] = PathOrOutline()
            larva.segment_color[] = larva.outline_color[]
        end
    end

    function reset()
        dragging[] = false
        initialstep[] = 0
        if iseditable(larva)
            larva.segment_visible[] = false
            larva.segment[] = PathOrOutline()
        end
    end

    stop = reset

    Makie.onmouseleftdragstart(mouseevents) do _
            @debug "Start to drag"
            start()
            return Consume(blocking)
        end
        return Consume(false)
    end

    Makie.onmouseleftdrag(mouseevents) do _
            @debug "Dragging"
            if iseditable(larva)
                timestep, segment, dist2 = pick_timesegment(scene, larva, initialstep)
                settimestep!(larva.controller, timestep)
                if dist2 > (2reference_larva_size)^2
                    assignmentfailed!(larva.controller, "mouse pointer away")
                    stop()
                else
                    if !(isempty(segment) || larva.segment_visible[])
                        larva.segment_visible[] = true
                    end
                    larva.segment[] = segment
                timestep, _, _ = pick_timestep(scene, larva)
                settimestep!(larva.controller, timestep)
            end
            return Consume(blocking)
        return Consume(false)
    Makie.onmouseleftdragstop(mouseevents) do _
            if iseditable(larva)
                if time() - dragstart[] > 1
                    assign_tag_to_segment!(larva, initialstep[])
                else
                    assignmentfailed!(larva.controller, "drag duration too short")
                end
        end
        return Consume(false)
    end

    on(taggingevents(larva.controller).assignmentfailed) do reason
        @info "Tag assignment cancelled ($reason)"
    end

end

function Makie.plot!(plot::LarvaPlot{Tuple{SingleLarvaView}})
    larva = plot[1][]
    scene = plot.parent
    setmouseevents!(scene, larva)
    larvaplot!(plot, larva.path, larva.outline, larva.outline_color, larva.visible)
    if iseditable(larva)
        lines!(plot, larva.segment; color=larva.segment_color, linewidth=6, visible=larva.segment_visible)
        translate!(plot.plots[end], Makie.Vec3f(0, 0, 0.1))
    end
François  LAURENT's avatar
François LAURENT committed

François  LAURENT's avatar
François LAURENT committed

struct DecoratedLarva
    larva::StatefulLarva
    activearea
    decorated::AbstractObservable{Bool}
function DecoratedLarva(larva::StatefulLarva)
    decorated = Observable(false)
    on(larva.visible) do visible
        # on larva disappearance
        if !visible && decorated[]
            decorated[] = false
        end
    end
    l, r = extrema([p[1] for p in larva.model.path])
    b, t = extrema([p[2] for p in larva.model.path])
    margin = theme[:DecoratedLarva][:decoration_margin]
    if 0 < margin
        l -= margin
        b -= margin
        r += margin
        t += margin
François  LAURENT's avatar
François LAURENT committed
    end
    bl, tr = Meshes.Point2f(l, b), Meshes.Point2f(r, t)
    br, tl = Meshes.Point2f(r, b), Meshes.Point2f(l, t)
    activearea = Meshes.Quadrangle(bl, br, tr, tl)
    DecoratedLarva(larva, activearea, decorated)
function DecoratedLarva(larva::LarvaModel, args...; kwargs...)
    DecoratedLarva(StatefulLarva(larva, args...; kwargs...))
theme[:DecoratedLarva] = Dict(:hover_color => :red,
                              :hover_linewidth => 4,
                              :decoration_margin => 2.0f0,
                             )

function Makie.plot!(plot::LarvaPlot{Tuple{DecoratedLarva}})
    decoratedlarva = plot[1][]
    decorated = decoratedlarva.decorated
    larva = decoratedlarva.larva
    # on(decorated) do b
    #     if b
    #         @info "larva #$(larva.model.id) decorated"
    #     end
    # end

    # plot
    larvaplot!(plot, larva)
    #decoratedlarva.primitive_child = plot.plots[1]

    # decoration
    outline = Vector(vertices(decoratedlarva.activearea))
    p = Makie.Point2f  coordinates
    close!(path) = push!(path, path[1])
    lines!(plot, close!(p.(outline));
           color=theme[:DecoratedLarva][:hover_color],
           linewidth=theme[:DecoratedLarva][:hover_linewidth],
           visible=decorated,
          )
    # TODO: plot larva ID?
end
François  LAURENT's avatar
François LAURENT committed

struct DecoratedLarvae
    larvae::Vector{DecoratedLarva}
    centers::Array{Float32, 2}
    norm2::Array{Float32, 2}
    hovered_larva::AbstractObservable{Int}
    hovering_active::AbstractObservable{Bool}
end
François  LAURENT's avatar
François LAURENT committed

function DecoratedLarvae(larvae::Vector{LarvaModel}, args...; kwargs...)
    larvae′ = DecoratedLarva[]
    for larva in larvae
        try
            push!(larvae′, DecoratedLarva(larva, args...; kwargs...))
        catch e
            @warn "Skipping track on failure" id=larva.id cause=e
        end
    end
    DecoratedLarvae(larvae′)
François  LAURENT's avatar
François LAURENT committed

function DecoratedLarvae(larvae::Vector{DecoratedLarva})
    # not-a-larva is encoded as index 0
    current_larva = Observable(0)
    hovered_larva = Observable(0)
    hovering_active = Observable(true)
    on(hovering_active) do active
        if !(active || isnothing(hovered_larva[]))
            hovered_larva[] = 0
        end
    end
    # note: if an active larva turns invisible, decorations will disappear,
    #       but the above observables won't be cleared
    on(hovered_larva) do j
        @assert hovering_active[]
        i = current_larva[]
        if i == j
            @warn "moving too fast?"
        end
        if 0 < i
            i_decorated = larvae[i].decorated
            if i_decorated[]
                i_decorated[] = false
            else
                @warn begin
                    i_id = larvae[i].larva.model.id
                    "larva #$(i_id)'s decorations already removed"
                end
            end
        end
        current_larva.val = j
        if 0 < j
            if larvae[j].larva.visible[]
                larvae[j].decorated[] = true
            else
                @error begin
                    j_id = larvae[j].larva.model.id
                    "cannot decorate invisible larva #$(j_id)"
                end
                hovered_larva.val = 0
            end
        end
    end
    center = Meshes.coordinates  Meshes.centroid
    centers = cat((center(larva.activearea) for larva in larvae)...; dims=2)
    norm2 = sum(centers .* centers, dims=1)
    DecoratedLarvae(larvae, centers, norm2, hovered_larva, hovering_active)
end
François  LAURENT's avatar
François LAURENT committed

function find(larvae::DecoratedLarvae, position)
    pos = Vector(position)'
    p2 = pos * pos'
    visible = [i for (i, larva) in enumerate(larvae.larvae) if larva.larva.visible[]]
    isempty(visible) && return 0
    dist2 = vec(larvae.norm2[:,visible] .+ p2 .- 2 * pos * larvae.centers[:,visible])
    bestlarva = visible[argmin(dist2)]
    if Meshes.Point2f(pos...)  larvae.larvae[bestlarva].activearea
        # not-a-larva is encoded as index 0
        bestlarva = 0
    end
    return bestlarva
end
François  LAURENT's avatar
François LAURENT committed

function Makie.plot!(plot::LarvaPlot{Tuple{DecoratedLarvae}})
    content = plot[1][]
    for larva in content.larvae
        larvaplot!(plot, larva)
    end
    scene = plot.parent
    on(events(scene).mouseposition) do _
        if content.hovering_active[]
            #pos = Makie.to_world(scene, Makie.screen_relative(scene, mp))
            pos = mouseposition(scene)
            larva = find(content, pos)
            if larva != content.hovered_larva[]
                content.hovered_larva[] = larva
            end
        end
        return Consume(false)
    end
end
François  LAURENT's avatar
François LAURENT committed

Meshes.boundingbox(larva::StatefulLarva) = Meshes.boundingbox(larva.model)
Meshes.boundingbox(larva::DecoratedLarva) = Meshes.boundingbox(larva.larva)
Meshes.boundingbox(larvae::DecoratedLarvae) = Meshes.boundingbox(map(Meshes.boundingbox, larvae.larvae))

function setmouseevents!(scene, plot::DecoratedLarvae, ctrl; consume=false)
François  LAURENT's avatar
François LAURENT committed
    on(async_latest(events(scene).mousebutton)) do mb
        if mb.button == Mouse.left && mb.action == Mouse.release
            larva_ix = plot.hovered_larva[]
            if 0 < larva_ix
                larva_id = plot.larvae[larva_ix].larva.model.id
                activatelarva!(ctrl, larva_id)
                consumed = true
            end
        end
        return Consume(consumed)
    end
François  LAURENT's avatar
François LAURENT committed
end
François  LAURENT's avatar
François LAURENT committed

const FIGSIZE = (1000, 750)

function autosize!(axis, size)
    target_ratio = Float32(size[1] / size[2])
    on(axis.targetlimits) do limits
        width, height = limits.widths
        ratio = width / height
        if 1e-3 <= abs(ratio - target_ratio)
            # enlarge the shortest dimension at constant aspect ratio to approach the target
            # width/height ratio
            x0, y0 = limits.origin
            if ratio < target_ratio
                width′= height * target_ratio
                origin = [x0 - (width′- width) / 2, y0]
                widths = [width′, height]
            else#if target_ratio < ratio
                height′= width / target_ratio
                origin = [x0, y0 - (height′- height) / 2]
                widths = [width, height′]
            end
            axis.targetlimits[] = typeof(limits)(origin, widths)
        end
    end
end