holoviz / holoviews

With Holoviews, your data visualizes itself.
https://holoviews.org
BSD 3-Clause "New" or "Revised" License
2.69k stars 403 forks source link

Easier access to available streams on HoloViews objects #4739

Open jbednar opened 3 years ago

jbednar commented 3 years ago

Right now a user can pretty easily set up a streams object with a HoloViews object as its source:

import panel as pn, xarray as xr, holoviews as hv, hvplot.xarray
pn.extension()

ds = xr.tutorial.open_dataset('air_temperature')
image = ds.hvplot('lon', 'lat')
stream = hv.streams.PointerXY(source=image, x=-88+360, y=40)
timeseries = ds.interactive.sel(lon=stream.param.x, 
                                lat=stream.param.y, 
                                method="nearest").hvplot('time')

pn.Column(image, timeseries.dmap())

However, discovering which streams might be applicable to a given HoloViews object requires studying the docs, and actually instantiating them requires importing HoloViews even if I'm otherwise working in hvPlot as I am here.

Could there be a .stream accessor object on HoloViews objects that can be dir() listed or tab completed to show the streams that are applicable to this object, and letting a user refer to them without having to come up with the invocation out of nothing? I'm thinking of something like:

import panel as pn, xarray as xr, holoviews as hv, hvplot.xarray
pn.extension()

ds = xr.tutorial.open_dataset('air_temperature')
image = ds.hvplot('lon', 'lat')
timeseries = ds.interactive.sel(lon=image.stream.hover.param.x, 
                                lat=image.stream.hover.param.y, 
                                method="nearest").hvplot('time')

pn.Column(image, timeseries.dmap())

Seems like this would make streams much more visible and accessible to users, and would make the workflow smoother for hvPlot users.

philippjfr commented 3 years ago

Here's a little hacky version I put together to play with this:

class StreamAccessor(object):

     def __init__(self, owner):
         self._owner = owner
         self._streams = {}

     @property
     def hover(self):
         if 'hover' not in self._streams:
             self._streams['hover'] = PointerXY(x=0, y=0, source=self._owner)
         return self._streams['hover']

     @property
     def box_select(self):
         if 'box_select' not in self._streams:
             self._streams['box_select'] = SelectionXY(
                 source=self._owner,
                 x_selection=(0, 0),
                 y_selection=(0, 0)
             )
         return self._streams['box_select']

     @property
     def tap(self):
         if 'tap' not in self._streams:
             self._streams['tap'] = Tap(x=0, y=0, source=self._owner)
         return self._streams['tap']

 def stream(self):
     if not hasattr(self, '_stream'):
         self._stream = StreamAccessor(self)
     return self._stream

 hv.Element.stream = property(stream)
 hv.DynamicMap.stream = property(stream)
jlstevens commented 3 years ago

How would you set the initialization?

The first example sets x and y in:

stream = hv.streams.PointerXY(source=image, x=-88+360, y=40)

Not sure how you would do this in the new proposal.

I am also wary of accessors to tab complete this sort of information. In particular, things like PointerXY (so it would be something like on=image.stream.pointerxy.param.x) are only available for bokeh/plotly (and then the set of available streams can vary between the backends). How does this work to enable the appropriate bokeh tools for example?

In addition, how do you know what stream is being specified? If use use just one argument, is image.stream.hover.param.x just PointerX? Is image.stream.hover.param.y just PointerY? Or if you use both is it PointerXY?

In addition, these kinds of streams have nothing really to do with HoloViews objects...they are more about Bokeh/Plotly plots imho. I support the idea of making it easier to find available streams but I am not too fond of this accessor idea.

philippjfr commented 3 years ago

Not sure how you would do this in the new proposal.

That's a solid point, but you can initialize the stream with:

object.stream.pointer.param.set_param(x=..., y=...)

(Yes pretty ugly)

things like PointerXY (so it would be something like on=image.stream.pointerxy.param.x) are only available for bokeh/plotly (and then the set of available streams can vary between the backends).

Streams are at least in principle supportable for all (existing) backends, e.g. I had a demo that allowed rendering HoloViews plots using ipympl, which would make it easy to support these streams and we should also support other interactive matplotlib backends. So I would not make it dependent on the backend at all. That said I think we should decide on a basic set of streams that we can support across all backends, which should include ranges, pointers and tap events.

In addition, how do you know what stream is being specified? If use use just one argument, is image.stream.hover.param.x just PointerX? Is image.stream.hover.param.y just PointerY? Or if you use both is it PointerXY?

You can of course add accessors for all three variants, but I'd argue PointerXY should just be called .pointer.

In addition, these kinds of streams have nothing really to do with HoloViews objects...they are more about Bokeh/Plotly plots imho.

All plotting backends we do have in principle support linked streams like this so I'm not particularly worried about this but conceptually it does require there to be the concept of a "view" associated with the object. However if you want to be completely conceptually pure then we also wouldn't have .opts which is about setting options associated view of the object.

We're back to the age old argument we always have which is conceptual purity vs. practicality 😄 . In any case I would definitely love to explore alternative proposals (if there are any).

jlstevens commented 3 years ago

This kind of accessor would be more appropriate at the hvplot level...something like foo.hvplot.streams... would be fine.

philippjfr commented 3 years ago

That doesn't work, hvplot returns HoloViews objects, which is what the streams bind to.

jlstevens commented 3 years ago

Maybe hvplot.streams(image).hover.x or something? Then if you do from hvplot import streams it could be streams(image).hover.x?

jlstevens commented 3 years ago

I would also be more open to the original idea if the streams accessor is added to HoloViews objects by hvplot as I don't think it belongs in the core. Doesn't seem too crazy as hvplot already monkey patches things.

jbednar commented 3 years ago

How would you set the initialization? The first example sets x and y in:

Note that I didn't want to set the initialization; I simply wanted it to work, and without some initial values this code fails with KeyError: nan. I'd really rather that be done for me, i.e. that somehow the stream has an initial value that correctly reflects some actual value it could eventually have in practice. I have no idea if that's achievable, but really I want that part to be done for me, rather than for me to have a convenient way to do it myself.

I support the idea of making it easier to find available streams but I am not too fond of this accessor idea.

Sounds like we're on the same page. I believe strongly that there is an opportunity here (i.e. a problem that users face), and would be happy with any good solution. The accessor idea seems pretty good to me, but a better one would be even better!

Maybe hvplot.streams(image).hover.x

I guess that would be ok, but one thing that's attractive to me about the accessor is the impression it gives that the streams are just always there ready to tap into if I want, which is conceptually clean and lets me not worry about holding pointers to objects, object lifetimes, etc. Of course that's a fiction, but it's a useful one, because I can act like it's true and reason about it and make the right decisions.

Whereas with a standalone function, as a user I would expect to have to retain a pointer to the object if I want to first pass x and then pass y separately as shown above:

h = hvplot.streams(image).hover
timeseries = ds.interactive.sel(lon=h.param.x, h.param.y, 
                                method="nearest").hvplot('time')

I.e., I now have this object h to worry about, right? Being able to grab a pointer to h is great; having to grab a pointer to it just so I can pass x to one argument and y to another is not.

This kind of accessor would be more appropriate at the hvplot level

Deciding between hvPlot and HoloViews seems difficult; by dependencies it belongs in HoloViews, since it relies on nothing from hvPlot, but all the motivation that I can think of for it is at the hvPlot level, not for direct HoloViews users. Tricky!