panel icon indicating copy to clipboard operation
panel copied to clipboard

Adds BeforeAfterSlider

Open MarcSkovMadsen opened this issue 3 years ago • 16 comments
trafficstars

Addresses #3006

Todo

  • [x] Get it working in edge, chrome and firefox
  • [x] Get it working in notebook.
  • [x] Reference Notebook
  • [x] Gallery Example showing it works with Plots, Maps, Images, Tables
  • [x] Be able to interact with before-after panels. Probably using draggable div. See https://www.w3schools.com/howto/howto_js_draggable.asp
  • [ ] Pass tests
  • [ ] Get review

Nice to Have

  • [ ] Before and after title.
  • [ ] Get before/ after Spacers to display initially. They display as soon as the window is resized.

MarcSkovMadsen avatar Dec 12 '21 05:12 MarcSkovMadsen

Hi @philippjfr

Can you give me a hint. The BeforeAfterSlider does not show Spacers before I resize the window. What do I need to do to trigger the to (re-)layout. The problem is that they get an initial height of 0px.

https://user-images.githubusercontent.com/42288570/145701289-7225fc5c-70df-4a7c-a08c-16abdb56b975.mp4

You can run panel serve 'panel/tests/layout/test_before_after_slider.py' --autoreload --show to explore the problem.

MarcSkovMadsen avatar Dec 12 '21 05:12 MarcSkovMadsen

Sliderthumb works

(Sliderthumb needs margin-top in chrome/ edge server. But not jupyter labs for some unknown reason)

Firefox Server

image

Edge Server

image

Chrome Server

image

Firefox Jupyterlab

image

Edge Jupyterlab

image

Chrome Jupyterlab

image

MarcSkovMadsen avatar Dec 16 '21 06:12 MarcSkovMadsen

I've added a notebook to the gallery with more examples

https://user-images.githubusercontent.com/42288570/146633408-66989364-31d5-450f-b1d5-869d7643241a.mp4

MarcSkovMadsen avatar Dec 18 '21 07:12 MarcSkovMadsen

I've refactored to use a draggable div instead of a range input. Before the range input was in front of everything making the before and after panels inactive. Now you can get tooltips on plots and edit the Tabulator tables.

MarcSkovMadsen avatar Dec 18 '21 19:12 MarcSkovMadsen

This is looking great! Will review next week. Would love to hear your thoughts on before/after as the API vs making it a ListLike limited to two items.

philippjfr avatar Dec 18 '21 20:12 philippjfr

Listlike is fine with me. I have a plan to contribute splitter.js based layout also. They are sort of related. So think similar api would be nice.

MarcSkovMadsen avatar Dec 18 '21 21:12 MarcSkovMadsen

I spent a lot of time trying to add a before and an after title in the top left and right corners.

But as soon as I put those in the HoloViews plot no longer wanted to find its width upfront. It looked like this

image

Try adding something like below in the _template and it will look like above with panel serve --autoreload examples/gallery/layout/BeforeAfterSliderExamples.ipynb.

<div style="width: 100%"><h3 style="position:relative;float:left;top:-35px">After</h3><h3 style="position:relative;float:right;top:-35px">Before</h3></div>

I believe its a natural thing to add. But maybe its ok to leave out for now.

image

MarcSkovMadsen avatar Dec 19 '21 06:12 MarcSkovMadsen

Hi @philippjfr

I will leave it as is for now waiting for your review. I would not know how to refactor into ListLike so please do and/ or provide examples. I would not know how you would like to restrict listlike to only 2 elements. And especially I would not know how to replace ${before} with a reference to the first item in the list.

If you provide some suggestions I will finalize. Thanks.

MarcSkovMadsen avatar Dec 19 '21 06:12 MarcSkovMadsen

Some suggestions here https://discourse.holoviz.org/t/compare-elements-containers-with-a-swipe-widget-idea-for-a-panel-widget/3151/8?u=marc

MarcSkovMadsen avatar Dec 20 '21 21:12 MarcSkovMadsen

I will leave it as is for now waiting for your review. I would not know how to refactor into ListLike so please do and/ or provide examples.

Okay I'll take a look, I'll probably leave before and after as properties so you can still do layout.before = ...

philippjfr avatar Dec 21 '21 11:12 philippjfr

I'm eager to see this appear! @MarcSkovMadsen , you probably don't know, but the Datashader logo is an abstraction of a imagined before/after comparison between a subsampled and a datashaded plot. It only exists in my head, but I've always wanted to make it an actual example, and soon I will be able to!

jbednar avatar Dec 23 '21 01:12 jbednar

@MarcSkovMadsen I've partially rewritten this but it still suffers from some issues with responsive contents. You had a partial fix for that simply by setting the width/height of the contents but that was only a partial fix because it would not work for layouts containing multiple components. I'm still looking for a general solution.

philippjfr avatar Dec 23 '21 15:12 philippjfr

Ok. Thanks.

If you want me to search for a solution please provide a minimum reproducible example such that I can understand and work with the issue.

MarcSkovMadsen avatar Dec 24 '21 05:12 MarcSkovMadsen

Hi, thanks for the good work! I was trying to test what I found on the 'before-after-slider' branch.

Below is the code used..

Some imports:

from IPython.core.display import display, HTML
import param
import holoviews as hv
from holoviews import opts
import panel as pn
from panel.reactive import ReactiveHTML
from panel.layout.base import ListLike
import numpy as np
hv.extension('bokeh')
pn.extension(sizing_mode="stretch_width")

Class (as I saw it implemented already)

class BeforeAfterSlider(ListLike, ReactiveHTML):
    """
    The BeforeAfterSlider layout enables you to quickly compare two
    panels layed out on top of each other with a part of the *before*
    panel shown on one side of a slider and a part of the
    *after* panel shown on the other side."""

    objects = param.List(default=[], bounds=(0, 2), doc="""
        The list of child objects that make up the layout.""", precedence=-1)

    slider_width = param.Integer(default=12, bounds=(0, 25), doc="""
        The width of the slider in pixels""")

    slider_color = param.Color(default="silver", doc="""
        The color of the slider""")

    value = param.Integer(50, bounds=(0, 100), doc="""
        The percentage of the *after* panel to show.""")

    _before = param.Parameter()

    _after = param.Parameter()

    _template = """
    <div id="container" class="before-after-container">
      <div id="before" class="outer">
        <div id="before-inner" class="inner">${_before}</div>
      </div>
      <div id="after" class="outer" style="overflow: hidden;">
        <div id="after-inner" class="inner">${_after}</div>
      </div>
      <div id="slider" class="slider" onmousedown="${script('drag')}"
           style="background: ${slider_color}; width: ${slider_width}px;">
      </div>
    </div>
    """

    _scripts = {
        'render': """
          set_size(container, model)
          self.adjustSlider()
        """,
        'after_layout': """
          self.value()
        """,
        'drag': """
          function endDrag() {
             document.removeEventListener('mouseup', endDrag);
             document.removeEventListener('mousemove', handleDrag);
           }
           function handleDrag(e) {
             e = e || window.event;
             e.preventDefault();
             current = e.clientX
             start = view.el.getBoundingClientRect().left
             value = parseInt(((current-start)/ container.clientWidth)*100)
             data.value = Math.max(0, Math.min(value, 100))
           }
           let e = event || window.event;
           e.preventDefault();
           document.addEventListener('mouseup', endDrag);
           document.addEventListener('mousemove', handleDrag);
        """,
        'value': """
           after.style.width = `calc(${data.value}% + 5px)`
           self.adjustSlider()
        """,
        'slider_width': """
           self.adjustSlider()
        """,
        'adjustSlider': """
           halfWidth = parseInt(data.slider_width/2)
           slider.style.marginLeft = `calc(${data.value}% + 5px - ${halfWidth}px)`
        """
    }

    def __init__(self, *objects, **params):
        if 'objects' in params and objects:
            raise ValueError(
                "Either supply objects as an positional argument or "
                "as a keyword argument, not both."
            )
        objects = params.pop('objects', objects)
        super().__init__(objects=list(objects), **params)

    @param.depends('objects', watch=True, on_init=True)
    def _update_layout(self):
        self._before = self.before
        self._after = self.after

    @property
    def before(self):
        return self[0] if len(self) else None

    @before.setter
    def before(self, before):
        self[0] = before

    @property
    def after(self):
        return self[1] if len(self) > 1 else None

    @after.setter
    def after(self, after):
        self[1] = after

Example image genereation taken from holoviews gallery:

ls = np.linspace(0, 10, 200)
xx, yy = np.meshgrid(ls, ls)

bounds=(-1,-1,1,1)   # Coordinate system: (left, bottom, right, top)
bounds=(0,0,200,200)

img = hv.Image(np.sin(xx)*np.cos(yy), bounds=bounds).opts(frame_height=400, data_aspect=1)
img2 = hv.Image(np.sin(xx)*np.cos(yy), bounds=bounds).opts(cmap='Reds', frame_height=400, data_aspect=1)

Showing the BeforeAfterSlider instance:

before_after = BeforeAfterSlider(img, img2, value=50, slider_width=3, slider_color='magenta', height=400)
before_after

swipe-widget-example

Amazing that you have already worked on this feature!. Not sure of the value of my tes, but I noticed a few things that I wanted to highlight:

  • the vertical line is lagging behind the actual moving of the delimiting line for the before and after objects in the above example (a detail)
  • it would be very good if the zoom and pan from the toolbar would apply to both before and after objects as together, i.e. would be good to be able to zoom and pan both e.g. images and modify the swipe line in several iterations without a reset of the container (this could be important)
  • would there be a way top limit the swipe line to the extent of the frame for say a holoviews image? (in above example the line is overplotting the axis labels and tics a bit, not a big worry though)

I am really eager to see this widget apply into geoviews too as I think would be possible given the path of implementation.

For what it's worth I found one more source of inspiration that might be useful (link here: https://www.w3schools.com/howto/howto_js_image_comparison.asp)

GeoVizNow avatar Jan 06 '22 13:01 GeoVizNow

@GeoVizNow , I think what you're asking for is something more tailored to Bokeh plots in particular rather than this very generic Panel-level approach, so that it would respect the bokeh zoom and axes. Those issues seem out of scope for this particular feature, but would be appropriate for such a feature implemented at the Bokeh level. We've done something like that using the techniques at http://holoviews.org/user_guide/Custom_Interactivity.html, but I can't find the code for it just now; it was much more specific to a particular type of plot but did respect the Bokeh plot constraints because it was implemented at the bokeh plot level.

jbednar avatar Jan 07 '22 23:01 jbednar

As an FYI, in PR https://github.com/bokeh/bokeh/pull/12083 (scheduled for bokeh 3.1) I implemented various interactive shapes, including spans, with the intention of having in-canvas UI controls, especially sliders. Interactive spans (movable, with bounds, etc.), together with improvements to clipping (e.g. customizable clip regions per renderer or a group of renderers), could be used to implement the proposed in this PR feature idiomatically and efficiently on top of bokeh 3.1.

mattpap avatar Aug 02 '22 21:08 mattpap

Finally found a solution here using the clip-path CSS property.

philippjfr avatar Mar 13 '23 13:03 philippjfr

pre-commit.ci autofix

philippjfr avatar Mar 13 '23 13:03 philippjfr

Codecov Report

Merging #3007 (4cc8851) into main (fc23efe) will decrease coverage by 0.08%. The diff coverage is 96.66%.

@@            Coverage Diff             @@
##             main    #3007      +/-   ##
==========================================
- Coverage   83.36%   83.29%   -0.08%     
==========================================
  Files         247      250       +3     
  Lines       36309    36521     +212     
==========================================
+ Hits        30270    30420     +150     
- Misses       6039     6101      +62     
Flag Coverage Δ
ui-tests 39.10% <89.07%> (-0.14%) :arrow_down:
unitexamples-tests 73.51% <44.16%> (-0.07%) :arrow_down:

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
panel/__init__.py 100.00% <ø> (ø)
panel/layout/swipe.py 90.47% <90.47%> (ø)
panel/layout/__init__.py 100.00% <100.00%> (ø)
panel/tests/layout/test_swipe.py 100.00% <100.00%> (ø)
panel/tests/ui/layout/test_swipe.py 100.00% <100.00%> (ø)

... and 15 files with indirect coverage changes

:mega: We’re building smart automated test selection to slash your CI/CD build times. Learn more

codecov[bot] avatar Mar 13 '23 13:03 codecov[bot]

Amazing to see this in, really great work (though I do not know all that lays behind)!! It works really nicely! Even zooming on overlaid holoviews images works in sync. with both images. Notice that the zoom box is only active on the top side image, but works in sync on both. Was trying an example with the following installed versions:

  • holoviews 1.16.0a2
  • panel 1.0.0b2
  • bokeh 3.1.0
### Test swipe layout
import holoviews as hv
import panel as pn
import xarray as xr
import hvplot.xarray
pn.extension(sizing_mode='stretch_width')

# Example code taken from https://hvplot.holoviz.org/reference/xarray/image.html
ds = xr.tutorial.open_dataset('air_temperature')

# time = '2014-01-01'
data_2013_01 = ds.sel(time='2013-01-01').mean('time') - 273  # convert to celcius
data_2014_01 = ds.sel(time='2014-01-01').mean('time') - 273  # convert to celcius

img_opts = dict(cmap='gray', frame_height=400, frame_width=400)
pn.layout.Swipe(data_2013_01.hvplot.image(title='2013'), data_2014_01.hvplot.image(title='2014'), value=49, slider_color='white')

Couple of more things noted:

  • would have been amazing to limit the height of the slider to the height of the frame if it being a holoviews element, but do not know if possible. If setting the slider_color to same as plot backgroud it is of less importance (shown in animation)
  • if panel sizing_mode='stretch_width' then the 'value' of slider seems to reference the full page width while the bokeh image is not (an observation, maybe as one would expect?).
  • the hover tooltips seem to be clipped by the slider. Would have been bonus if they are not clipped.
  • the box zoom is only visually traced with stippled black rectangle on the top side 'after' image, but works in both.

2023-03-18_20-57-00

Again, really great to see this being possible!! Thanks @philippjfr, @MarcSkovMadsen, @jbednar, @mattpap and others involved!!

GeoVizNow avatar Mar 18 '23 20:03 GeoVizNow

One more note. I added a title to each holoviews element through hvpot. Was there any thought of title for each 'before' , 'after' object too? The way added in above example makes it align badly for the inconveniently for the 'after' image..

GeoVizNow avatar Mar 18 '23 20:03 GeoVizNow

By the way, is it ok to discuss this here even though the issue is merged or do you prefer a different place?

GeoVizNow avatar Mar 19 '23 20:03 GeoVizNow

A new issue about any suggestions for improvement would be good. Titles are definitely a good one.

philippjfr avatar Mar 19 '23 20:03 philippjfr

@philippjfr, thanks will create a new issue with one or more enhancement proposals.

GeoVizNow avatar Mar 20 '23 13:03 GeoVizNow