From 557306451bc0aadea8aa3e948eff73642163e5ac Mon Sep 17 00:00:00 2001 From: jkrumbiegel <22495855+jkrumbiegel@users.noreply.github.com> Date: Sat, 27 Mar 2021 19:09:07 +0100 Subject: [PATCH] add IntervalSlider (#675) * add basic rangeslider * add snap * add center shift to rangeslider * fix crossover logic * make sliderrange dynamically changeable * fix dynamic sliderrange * comment * rename to intervalslider * fix duplicated functions * return real values from `set_close_to!` * update docs * update docs * add intervalslider page * add IntervalSlider to basic MakieLayout test * resolution fixed --- docs/make.jl | 1 + docs/src/makielayout/intervalslider.md | 52 ++++ docs/src/makielayout/slider.md | 18 +- src/makielayout/MakieLayout.jl | 2 + src/makielayout/defaultattributes.jl | 55 +++- src/makielayout/layoutables/intervalslider.jl | 280 ++++++++++++++++++ src/makielayout/layoutables/slider.jl | 35 ++- src/makielayout/types.jl | 2 + test/unit_tests/makielayout.jl | 1 + 9 files changed, 423 insertions(+), 23 deletions(-) create mode 100644 docs/src/makielayout/intervalslider.md create mode 100644 src/makielayout/layoutables/intervalslider.jl diff --git a/docs/make.jl b/docs/make.jl index 1fc906ad3..13d25f487 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -369,6 +369,7 @@ makedocs( "makielayout/button.md", "makielayout/colorbar.md", "makielayout/gridlayout.md", + "makielayout/intervalslider.md", "makielayout/label.md", "makielayout/legend.md", "makielayout/lscene.md", diff --git a/docs/src/makielayout/intervalslider.md b/docs/src/makielayout/intervalslider.md new file mode 100644 index 000000000..aa3142b93 --- /dev/null +++ b/docs/src/makielayout/intervalslider.md @@ -0,0 +1,52 @@ +# IntervalSlider + +The interval slider selects an interval (low, high) from the supplied attribute `range`. +The (approximate) start values can be set with `startvalues`. + +The currently selected interval is in the attribute `interval` and is a Tuple of `(low, high)`. +Don't change this value manually, but use the function `set_close_to!(intslider, v1, v2)`. +This is necessary to ensure the values are actually present in the `range` attribute. + +You can click anywhere outside of the currently selected range and the closer interval edge will jump to the point. +You can then drag the edge around. +When hovering over the slider, the larger button indicates the edge that will react. + +If the mouse hovers over the central area of the interval and both buttons are enlarged, clicking and dragging shifts the interval around as a whole. + +You can double-click the slider to reset it to the values present in `startvalues`. +If `startvalues === AbstractPlotting.automatic`, the full interval will be selected (this is the default). + +If you set the attribute `snap = false`, the slider will move continously while dragging and only jump to the closest available values when releasing the mouse. + + +```@example +using CairoMakie +AbstractPlotting.inline!(true) # hide +CairoMakie.activate!() # hide + +f = Figure(resolution = (800, 800)) +Axis(f[1, 1], limits = (0, 1, 0, 1)) + +rs_h = IntervalSlider(f[2, 1], range = LinRange(0, 1, 1000), + startvalues = (0.2, 0.8)) +rs_v = IntervalSlider(f[1, 2], range = LinRange(0, 1, 1000), + startvalues = (0.4, 0.9), horizontal = false) + +Label(f[3, 1], @lift(string(round.($(rs_h.interval), digits = 2))), + tellwidth = false) +Label(f[1, 3], @lift(string(round.($(rs_v.interval), digits = 2))), + tellheight = false, rotation = pi/2) + +points = rand(Point2f0, 300) + +# color points differently if they are within the two intervals +colors = lift(rs_h.interval, rs_v.interval) do h_int, v_int + map(points) do p + (h_int[1] < p[1] < h_int[2]) && (v_int[1] < p[2] < v_int[2]) + end +end + +scatter!(points, color = colors, colormap = [:black, :orange], strokewidth = 0) + +f +``` \ No newline at end of file diff --git a/docs/src/makielayout/slider.md b/docs/src/makielayout/slider.md index 13e6a546a..6adb6ceee 100644 --- a/docs/src/makielayout/slider.md +++ b/docs/src/makielayout/slider.md @@ -7,7 +7,15 @@ CairoMakie.activate!() A simple slider without a label. You can create a label using a `Label` object, for example. You need to specify a range that constrains the slider's possible values. -You can then lift the `value` observable to make interactive plots. + +The currently selected value is in the attribute `value`. +Don't change this value manually, but use the function `set_close_to!(slider, value)`. +This is necessary to ensure the value is actually present in the `range` attribute. + +You can double-click the slider to reset it (approximately) to the value present in `startvalue`. + +If you set the attribute `snap = false`, the slider will move continously while dragging and only jump to the closest available value when releasing the mouse. + ```@example using CairoMakie @@ -30,6 +38,8 @@ limits!(ax, 0, 10, 0, 10) fig ``` +## Labelled slider convenience functions + To create a horizontal layout containing a label, a slider, and a value label, use the convenience function [`AbstractPlotting.MakieLayout.labelslider!`](@ref), or, if you need multiple aligned rows of sliders, use [`AbstractPlotting.MakieLayout.labelslidergrid!`](@ref). ```@example @@ -62,9 +72,3 @@ set_close_to!(lsgrid.sliders[3], 15.9) fig ``` - -If you want to programmatically move the slider, use the function [`AbstractPlotting.MakieLayout.set_close_to!`](@ref). -Don't manipulate the `value` attribute directly, as there is no guarantee that -this value exists in the range underlying the slider, and the slider's displayed value would -not change anyway by changing the slider's output. - diff --git a/src/makielayout/MakieLayout.jl b/src/makielayout/MakieLayout.jl index f95448ee5..8a0b62aed 100644 --- a/src/makielayout/MakieLayout.jl +++ b/src/makielayout/MakieLayout.jl @@ -56,6 +56,7 @@ include("layoutables/axis.jl") include("layoutables/colorbar.jl") include("layoutables/label.jl") include("layoutables/slider.jl") +include("layoutables/intervalslider.jl") include("layoutables/button.jl") include("layoutables/box.jl") include("layoutables/toggle.jl") @@ -66,6 +67,7 @@ include("layoutables/textbox.jl") export Axis export Slider +export IntervalSlider export Button export Colorbar export Label diff --git a/src/makielayout/defaultattributes.jl b/src/makielayout/defaultattributes.jl index c7201141a..4612c1a5c 100644 --- a/src/makielayout/defaultattributes.jl +++ b/src/makielayout/defaultattributes.jl @@ -577,14 +577,14 @@ function default_attributes(::Type{Slider}, scene) "The height setting of the slider." height = Auto() "The range of values that the slider can pick from." - range = 0:10 + range = 0:0.01:10 "Controls if the parent layout can adjust to this element's width" tellwidth = true "Controls if the parent layout can adjust to this element's height" tellheight = true "The start value of the slider or the value that is closest in the slider range." startvalue = 0 - "The current value of the slider." + "The current value of the slider. Don't set this manually, use the function `set_close_to!`." value = 0 "The width of the slider line" linewidth = 15 @@ -598,6 +598,8 @@ function default_attributes(::Type{Slider}, scene) horizontal = true "The align mode of the slider in its parent GridLayout." alignmode = Inside() + "Controls if the button snaps to valid positions or moves freely" + snap = true end (attributes = attrs, documentation = docdict, defaults = defaultdict) end @@ -612,6 +614,55 @@ end) """ Slider +function default_attributes(::Type{IntervalSlider}, scene) + attrs, docdict, defaultdict = @documented_attributes begin + "The horizontal alignment of the slider in its suggested bounding box." + halign = :center + "The vertical alignment of the slider in its suggested bounding box." + valign = :center + "The width setting of the slider." + width = Auto() + "The height setting of the slider." + height = Auto() + "The range of values that the slider can pick from." + range = 0:0.01:10 + "Controls if the parent layout can adjust to this element's width" + tellwidth = true + "Controls if the parent layout can adjust to this element's height" + tellheight = true + "The start values of the slider or the values that are closest in the slider range." + startvalues = AbstractPlotting.automatic + "The current interval of the slider. Don't set this manually, use the function `set_close_to!`." + interval = (0, 0) + "The width of the slider line" + linewidth = 15 + "The color of the slider when the mouse hovers over it." + color_active_dimmed = COLOR_ACCENT_DIMMED[] + "The color of the slider when the mouse clicks and drags the slider." + color_active = COLOR_ACCENT[] + "The color of the slider when it is not interacted with." + color_inactive = RGBf0(0.94, 0.94, 0.94) + "Controls if the slider has a horizontal orientation or not." + horizontal = true + "The align mode of the slider in its parent GridLayout." + alignmode = Inside() + "Controls if the buttons snap to valid positions or move freely" + snap = true + end + (attributes = attrs, documentation = docdict, defaults = defaultdict) +end + +@doc """ +IntervalSlider has the following attributes: + +$(let + _, docs, defaults = default_attributes(IntervalSlider, nothing) + docvarstring(docs, defaults) +end) +""" +IntervalSlider + + function default_attributes(::Type{Toggle}, scene) attrs, docdict, defaultdict = @documented_attributes begin "The horizontal alignment of the toggle in its suggested bounding box." diff --git a/src/makielayout/layoutables/intervalslider.jl b/src/makielayout/layoutables/intervalslider.jl new file mode 100644 index 000000000..62cfdcee4 --- /dev/null +++ b/src/makielayout/layoutables/intervalslider.jl @@ -0,0 +1,280 @@ +function layoutable(::Type{IntervalSlider}, fig_or_scene; bbox = nothing, kwargs...) + + topscene = get_topscene(fig_or_scene) + + default_attrs = default_attributes(IntervalSlider, topscene).attributes + theme_attrs = subtheme(topscene, :IntervalSlider) + attrs = merge!(merge!(Attributes(kwargs), theme_attrs), default_attrs) + + decorations = Dict{Symbol, Any}() + + @extract attrs ( + halign, valign, horizontal, linewidth, snap, + startvalues, interval, color_active, color_active_dimmed, color_inactive + ) + + sliderrange = attrs.range + + protrusions = Node(GridLayoutBase.RectSides{Float32}(0, 0, 0, 0)) + layoutobservables = LayoutObservables{IntervalSlider}(attrs.width, attrs.height, attrs.tellwidth, attrs.tellheight, + halign, valign, attrs.alignmode; suggestedbbox = bbox, protrusions = protrusions) + + onany(linewidth, horizontal) do lw, horizontal + if horizontal + layoutobservables.autosize[] = (nothing, Float32(lw)) + else + layoutobservables.autosize[] = (Float32(lw), nothing) + end + end + + sliderbox = lift(identity, layoutobservables.computedbbox) + + endpoints = lift(sliderbox, horizontal) do bb, horizontal + + h = height(bb) + w = width(bb) + + if horizontal + y = bottom(bb) + h / 2 + [Point2f0(left(bb) + h/2, y), + Point2f0(right(bb) - h/2, y)] + else + x = left(bb) + w / 2 + [Point2f0(x, bottom(bb) + w/2), + Point2f0(x, top(bb) - w/2)] + end + end + + # this is the index of the selected value in the slider's range + # selected_index = Node(1) + # add the selected index to the attributes so it can be manipulated later + attrs.selected_indices = (1, 1) + selected_indices = attrs.selected_indices + + # the fraction on the slider corresponding to the selected_indices + # this is only used after dragging + sliderfractions = lift(selected_indices, sliderrange) do is, r + map(is) do i + (i - 1) / (length(r) - 1) + end + end + + dragging = Node(false) + + # what the slider actually displays currently (also during dragging when + # the slider position is in an "invalid" position given the slider's range) + displayed_sliderfractions = Node((0.0, 0.0)) + + on(sliderfractions) do fracs + # only update displayed fraction through sliderfraction if not dragging + # dragging overrides the value so there is clear mouse interaction + if !dragging[] + displayed_sliderfractions[] = fracs + end + end + + # when the range is changed, switch to closest interval + on(sliderrange) do rng + selected_indices[] = closest_index.(Ref(rng), interval[]) + end + + on(selected_indices) do is + interval[] = getindex.(Ref(sliderrange[]), is) + end + + # initialize slider value with closest from range + selected_indices[] = if startvalues[] === AbstractPlotting.automatic + (1, lastindex(sliderrange[])) + else + closest_index.(Ref(sliderrange[]), startvalues[]) + end + + middlepoints = lift(endpoints, displayed_sliderfractions) do ep, sfs + [Point2f0(ep[1] .+ sf .* (ep[2] .- ep[1])) for sf in sfs] + end + + linepoints = lift(endpoints, middlepoints) do eps, middles + [eps[1], middles[1], middles[1], middles[2], middles[2], eps[2]] + end + + linecolors = lift(color_active_dimmed, color_inactive) do ca, ci + [ci, ca, ci] + end + + endbuttoncolors = lift(color_active_dimmed, color_inactive) do ca, ci + [ci, ci] + end + + endbuttons = scatter!(topscene, endpoints, color = endbuttoncolors, markersize = linewidth, strokewidth = 0, raw = true) + decorations[:endbuttons] = endbuttons + + linesegs = linesegments!(topscene, linepoints, color = linecolors, linewidth = linewidth, raw = true) + decorations[:linesegments] = linesegs + + state = Node(:none) + button_magnifications = lift(state) do state + if state == :none + [1.0, 1.0] + elseif state == :min + [1.25, 1.0] + elseif state == :both + [1.25, 1.25] + else + [1.0, 1.25] + end + end + buttonsizes = @lift($linewidth .* $button_magnifications) + buttons = scatter!(topscene, middlepoints, color = color_active, strokewidth = 0, markersize = buttonsizes, raw = true) + decorations[:buttons] = buttons + + mouseevents = addmouseevents!(topscene, linesegs, buttons) + + # we need to record where a drag started for the case where the center of the + # range is shifted, because the difference in indices always needs to stay the same + # and the slider is moved relative to this start position + startfraction = Ref(0.0) + start_disp_fractions = Ref((0.0, 0.0)) + startindices = Ref((1, 1)) + + onmouseleftdrag(mouseevents) do event + + dragging[] = true + fraction = if horizontal[] + (event.px[1] - endpoints[][1][1]) / (endpoints[][2][1] - endpoints[][1][1]) + else + (event.px[2] - endpoints[][1][2]) / (endpoints[][2][2] - endpoints[][1][2]) + end + fraction = clamp(fraction, 0, 1) + + if state[] in (:min, :max) + if snap[] + snapindex = closest_fractionindex(sliderrange[], fraction) + fraction = (snapindex - 1) / (length(sliderrange[]) - 1) + end + if state[] == :min + # if the mouse crosses over the current max, reverse + if fraction > displayed_sliderfractions[][2] + state[] = :max + displayed_sliderfractions[] = (displayed_sliderfractions[][2], fraction) + else + displayed_sliderfractions[] = (fraction, displayed_sliderfractions[][2]) + end + else + # if the mouse crosses over the current min, reverse + if fraction < displayed_sliderfractions[][1] + state[] = :min + displayed_sliderfractions[] = (fraction, displayed_sliderfractions[][1]) + else + displayed_sliderfractions[] = (displayed_sliderfractions[][1], fraction) + end + end + newindices = closest_fractionindex.(Ref(sliderrange[]), displayed_sliderfractions[]) + + if selected_indices[] != newindices + selected_indices[] = newindices + end + elseif state[] == :both + fracdif = fraction - startfraction[] + + clamped_fracdif = clamp(fracdif, -start_disp_fractions[][1], 1 - start_disp_fractions[][2]) + + ntarget = round(Int, length(sliderrange[]) * clamped_fracdif) + + nlow = -startindices[][1] + 1 + nhigh = length(sliderrange[]) - startindices[][2] + nchange = clamp(ntarget, nlow, nhigh) + + newindices = startindices[] .+ nchange + + displayed_sliderfractions[] = if snap[] + (newindices .- 1) ./ (length(sliderrange[]) - 1) + else + start_disp_fractions[] .+ clamped_fracdif + end + + if selected_indices[] != newindices + selected_indices[] = newindices + end + end + end + + onmouseleftdragstop(mouseevents) do event + dragging[] = false + # adjust slider to closest legal value + sliderfractions[] = sliderfractions[] + end + + onmouseleftdown(mouseevents) do event + + pos = event.px + + dim = horizontal[] ? 1 : 2 + frac = clamp( + (pos[dim] - endpoints[][1][dim]) / (endpoints[][2][dim] - endpoints[][1][dim]), + 0, 1 + ) + + startfraction[] = frac + startindices[] = selected_indices[] + start_disp_fractions[] = displayed_sliderfractions[] + + if state[] in (:both, :none) + return + end + + newindex = closest_fractionindex(sliderrange[], frac) + if abs(newindex - selected_indices[][1]) < abs(newindex - selected_indices[][2]) + selected_indices[] = (newindex, selected_indices[][2]) + else + selected_indices[] = (selected_indices[][1], newindex) + end + # linecolors[] = [color_active[], color_inactive[]] + end + + onmouseleftdoubleclick(mouseevents) do event + selected_indices[] = selected_indices[] = if startvalues[] === AbstractPlotting.automatic + (1, lastindex(sliderrange[])) + else + closest_index.(Ref(sliderrange[]), startvalues[]) + end + end + + onmouseover(mouseevents) do event + fraction = if horizontal[] + (event.px[1] - endpoints[][1][1]) / (endpoints[][2][1] - endpoints[][1][1]) + else + (event.px[2] - endpoints[][1][2]) / (endpoints[][2][2] - endpoints[][1][2]) + end + fraction = clamp(fraction, 0, 1) + + buttondistance = displayed_sliderfractions[][2] - displayed_sliderfractions[][1] + + state[] = if fraction < displayed_sliderfractions[][1] + 0.25 * buttondistance + :min + elseif fraction < displayed_sliderfractions[][1] + 0.75 * buttondistance + :both + else + :max + end + end + + onmouseout(mouseevents) do event + state[] = :none + end + + # trigger autosize through linewidth for first layout + linewidth[] = linewidth[] + + IntervalSlider(fig_or_scene, layoutobservables, attrs, decorations) +end + + +""" +Set the `slider` to the values in the slider's range that are closest to `v1` and `v2`, and return those values ordered min, max. +""" +function set_close_to!(intervalslider::IntervalSlider, v1, v2) + mima = minmax(v1, v2) + indices = closest_index.(Ref(intervalslider.range[]), mima) + intervalslider.selected_indices[] = indices + getindex.(Ref(intervalslider.range[]), indices) +end diff --git a/src/makielayout/layoutables/slider.jl b/src/makielayout/layoutables/slider.jl index b8d429a6e..e19fc97e2 100644 --- a/src/makielayout/layoutables/slider.jl +++ b/src/makielayout/layoutables/slider.jl @@ -9,7 +9,7 @@ function layoutable(::Type{Slider}, fig_or_scene; bbox = nothing, kwargs...) decorations = Dict{Symbol, Any}() @extract attrs ( - halign, valign, horizontal, linewidth, + halign, valign, horizontal, linewidth, snap, startvalue, value, color_active, color_active_dimmed, color_inactive ) @@ -71,6 +71,11 @@ function layoutable(::Type{Slider}, fig_or_scene; bbox = nothing, kwargs...) end end + # when the range is changed, switch to closest value + on(sliderrange) do rng + selected_index[] = closest_index(rng, value[]) + end + on(selected_index) do i value[] = sliderrange[][i] end @@ -107,19 +112,20 @@ function layoutable(::Type{Slider}, fig_or_scene; bbox = nothing, kwargs...) dragging[] = true dif = event.px - event.prev_px - fraction = if horizontal[] - dif[1] / (endpoints[][2][1] - endpoints[][1][1]) + fraction = clamp(if horizontal[] + (event.px[1] - endpoints[][1][1]) / (endpoints[][2][1] - endpoints[][1][1]) else - dif[2] / (endpoints[][2][2] - endpoints[][1][2]) + (event.px[2] - endpoints[][1][2]) / (endpoints[][2][2] - endpoints[][1][2]) + end, 0, 1) + + newindex = closest_fractionindex(sliderrange[], fraction) + if snap[] + fraction = (newindex - 1) / (length(sliderrange[]) - 1) end - if fraction != 0.0f0 - newfraction = min(max(displayed_sliderfraction[] + fraction, 0f0), 1f0) - displayed_sliderfraction[] = newfraction - - newindex = closest_fractionindex(sliderrange[], newfraction) - if selected_index[] != newindex - selected_index[] = newindex - end + displayed_sliderfraction[] = fraction + + if selected_index[] != newindex + selected_index[] = newindex end end @@ -198,9 +204,10 @@ function closest_index_inexact(sliderrange, value) end """ -Set the `slider` to the value in the slider's range that is closest to `value`. +Set the `slider` to the value in the slider's range that is closest to `value` and return this value. """ -function set_close_to!(slider, value) +function set_close_to!(slider::Slider, value) closest = closest_index(slider.range[], value) slider.selected_index = closest + slider.range[][closest] end diff --git a/src/makielayout/types.jl b/src/makielayout/types.jl index 08dbfec34..11219401e 100644 --- a/src/makielayout/types.jl +++ b/src/makielayout/types.jl @@ -134,6 +134,8 @@ end @Layoutable Slider +@Layoutable IntervalSlider + @Layoutable Button @Layoutable Toggle diff --git a/test/unit_tests/makielayout.jl b/test/unit_tests/makielayout.jl index eac9d2e5a..3f6af84c8 100644 --- a/test/unit_tests/makielayout.jl +++ b/test/unit_tests/makielayout.jl @@ -14,5 +14,6 @@ te = layout[0, :] = Label(scene, "A super title") me = layout[end + 1, :] = Menu(scene, options=["one", "two", "three"]) tb = layout[end + 1, :] = Textbox(scene) + is = layout[end + 1, :] = IntervalSlider(scene) @test true end \ No newline at end of file