Skip to content

Commit

Permalink
Ensure Range streams and RangeToolLink respect subcoordinate axis ran…
Browse files Browse the repository at this point in the history
…ge (#6256)
  • Loading branch information
philippjfr authored Jun 7, 2024
1 parent bfadd80 commit 4db648d
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 29 deletions.
9 changes: 7 additions & 2 deletions holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,10 @@ def __init__(self, element, plot=None, **params):
super().__init__(element, **params)
self.handles = {} if plot is None else self.handles['plot']
self.static = len(self.hmap) == 1 and len(self.keys) == len(self.hmap)
self.callbacks, self.source_streams = self._construct_callbacks()
if isinstance(self, GenericOverlayPlot):
self.callbacks, self.source_streams = [], []
else:
self.callbacks, self.source_streams = self._construct_callbacks()
self.static_source = False
self.streaming = [s for s in self.streams if isinstance(s, Buffer)]
self.geographic = bool(self.hmap.last.traverse(lambda x: x, Tiles))
Expand Down Expand Up @@ -1123,7 +1126,7 @@ def _axis_properties(self, axis, key, plot, dimension=None,
ticker = self.xticks if axis == 'x' else self.yticks
if not (self._subcoord_overlaid and axis == 'y'):
axis_props.update(get_ticker_axis_props(ticker))
else:
elif not self.drawn:
ticks, labels = [], []
idx = 0
for el, sp in zip(self.current_frame, self.subplots.values()):
Expand Down Expand Up @@ -2104,6 +2107,7 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None):
self.handles['x_range'], self.handles['y_range'] = plot_ranges
if self._subcoord_overlaid:
if style_element.label in plot.extra_y_ranges:
self.handles['subcoordinate_y_range'] = plot.y_range
self.handles['y_range'] = plot.extra_y_ranges.pop(style_element.label)

if self.apply_hard_bounds:
Expand Down Expand Up @@ -2973,6 +2977,7 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
def __init__(self, overlay, **kwargs):
self._multi_y_propagation = self.lookup_options(overlay, 'plot').options.get('multi_y', False)
super().__init__(overlay, **kwargs)
self.callbacks, self.source_streams = self._construct_callbacks()
self._multi_y_propagation = False

@property
Expand Down
64 changes: 42 additions & 22 deletions holoviews/plotting/bokeh/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from bokeh.models import CustomJS, Toolbar
from bokeh.models.tools import RangeTool

from ...core.spaces import HoloMap
from ...core.util import isscalar
from ..links import (
DataLink,
Expand Down Expand Up @@ -94,31 +95,45 @@ def find_links(cls, root_plot):
# If link has no target don't look further
found.append((link, plot, None))
continue
potentials = [cls.find_link(p, link) for p in plots]
potentials = [cls.find_link(p, link, target=True) for p in plots]
tgt_links = [p for p in potentials if p is not None]
if tgt_links:
found.append((link, plot, tgt_links[0][0]))
return found

@classmethod
def find_link(cls, plot, link=None):
def find_link(cls, plot, link=None, target=False):
"""
Searches a GenericElementPlot for a Link.
Searches a plot for any Links declared on the sources of the plot.
Args:
plot: The plot to search for Links
link: A Link instance to check for matches
target: Whether to check against the Link.target
Returns:
A tuple containing the matched plot and list of matching Links.
"""
registry = Link.registry.items()
attr = 'target' if target else 'source'
if link is None:
candidates = list(Link.registry.items())
else:
candidates = [(getattr(link, attr), [link])]
for source in plot.link_sources:
if link is None:
links = [
l for src, links in registry for l in links
if src is source or (src._plot_id is not None and
src._plot_id == source._plot_id)]
for link_src, src_links in candidates:
if not plot._sources_match(link_src, source):
continue
links = []
for link in src_links:
# Skip if Link.target is an overlay but the plot isn't
# or if the target is an element but the plot isn't
src = getattr(link, attr)
src_el = src.last if isinstance(src, HoloMap) else src
if not plot._matching_plot_type(src_el):
continue
links.append(link)
if links:
return (plot, links)
elif ((link.target is source) or
(link.target is not None and
link.target._plot_id is not None and
link.target._plot_id == source._plot_id)):
return (plot, [link])

def validate(self):
"""
Expand All @@ -141,25 +156,30 @@ def __init__(self, root_model, link, source_plot, target_plot):
if axis not in link.axes:
continue

axes[f'{axis}_range'] = target_plot.handles[f'{axis}_range']
range_name = f'{axis}_range'
if f'subcoordinate_{axis}_range' in target_plot.handles:
target_range_name = f'subcoordinate_{range_name}'
else:
target_range_name = range_name
axes[range_name] = ax = target_plot.handles[target_range_name]
interval = getattr(link, f'intervals{axis}', None)
if interval is not None and bokeh34:
min, max = interval
if min is not None:
axes[f'{axis}_range'].min_interval = min
ax.min_interval = min
if max is not None:
axes[f'{axis}_range'].max_interval = max
self._set_range_for_interval(axes[f'{axis}_range'], max)
ax.max_interval = max
self._set_range_for_interval(ax, max)

bounds = getattr(link, f'bounds{axis}', None)
if bounds is not None:
start, end = bounds
if start is not None:
axes[f'{axis}_range'].start = start
axes[f'{axis}_range'].reset_start = start
ax.start = start
ax.reset_start = start
if end is not None:
axes[f'{axis}_range'].end = end
axes[f'{axis}_range'].reset_end = end
ax.end = end
ax.reset_end = end

tool = RangeTool(**axes)
source_plot.state.add_tools(tool)
Expand Down
37 changes: 32 additions & 5 deletions holoviews/plotting/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,19 @@ class CallbackPlot:

backend = None

@staticmethod
def _sources_match(src1, src2):
return src1 is src2 or (src1._plot_id is not None and src1._plot_id == src2._plot_id)

def _matching_plot_type(self, element):
"""
Checks if the plot type matches the element type.
"""
return (
(not isinstance(element, CompositeOverlay) or isinstance(self, GenericOverlayPlot) or self.batched) and
(not isinstance(element, Element) or not isinstance(self, GenericOverlayPlot))
)

def _construct_callbacks(self):
"""
Initializes any callbacks for streams which have defined
Expand All @@ -979,10 +992,18 @@ def _construct_callbacks(self):
registry = list(Stream.registry.items())
callbacks = Stream._callbacks[self.backend]
for source in self.link_sources:
streams = [
s for src, streams in registry for s in streams
if src is source or (src._plot_id is not None and
src._plot_id == source._plot_id)]
streams = []
for stream_src, src_streams in registry:
# Skip if source identities do not match
if not self._sources_match(stream_src, source):
continue
for stream in src_streams:
# Skip if Stream.source is an overlay but the plot isn't
# or if the source is an element but the plot isn't
src_el = stream.source.last if isinstance(stream.source, HoloMap) else stream.source
if not self._matching_plot_type(src_el):
continue
streams.append(stream)
cb_classes |= {(callbacks[type(stream)], stream) for stream in streams
if type(stream) in callbacks and stream.linked
and stream.source is not None}
Expand All @@ -1007,7 +1028,13 @@ def link_sources(self):
zorders = [self.zorder]

if isinstance(self, GenericOverlayPlot) and not self.batched:
sources = [self.hmap.last]
if self.overlaid:
sources = [self.hmap.last]
else:
sources = [
o for i, inputs in self.stream_sources.items()
for o in inputs
]
elif not self.static or isinstance(self.hmap, DynamicMap):
sources = [o for i, inputs in self.stream_sources.items()
for o in inputs if i in zorders]
Expand Down
41 changes: 41 additions & 0 deletions holoviews/tests/plotting/bokeh/test_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,44 @@ def test_rangexy_multi_yaxes():
# Ensure both callbacks are attached
assert p1.callbacks[0].plot is p1
assert p2.callbacks[0].plot is p2


@pytest.mark.usefixtures('bokeh_backend')
def test_rangexy_subcoordinate_y():
c1 = Curve(np.arange(100).cumsum(), vdims='y', label='A').opts(subcoordinate_y=True)
c2 = Curve(-np.arange(100).cumsum(), vdims='y2', label='B').opts(subcoordinate_y=True)

overlay = (c1 * c2)
RangeXY(source=overlay)

plot = bokeh_server_renderer.get_plot(overlay)

p1, p2 = plot.subplots.values()

assert not p1.callbacks
assert not p2.callbacks
assert len(plot.callbacks) == 1
callback = plot.callbacks[0]
assert callback._process_msg({}) == {}


@pytest.mark.usefixtures('bokeh_backend')
def test_rangexy_subcoordinate_y_dynamic():

def cb(x_range, y_range):
return (
Curve(np.arange(100).cumsum(), vdims='y', label='A').opts(subcoordinate_y=True) *
Curve(-np.arange(100).cumsum(), vdims='y2', label='B').opts(subcoordinate_y=True)
)

stream = RangeXY()
dmap = DynamicMap(cb, streams=[stream])
plot = bokeh_server_renderer.get_plot(dmap)

p1, p2 = plot.subplots.values()

assert not p1.callbacks
assert not p2.callbacks
assert len(plot.callbacks) == 1
callback = plot.callbacks[0]
assert callback._process_msg({}) == {}
30 changes: 30 additions & 0 deletions holoviews/tests/ui/bokeh/test_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,33 @@ def popup_form(index):
locator = page.locator("#lasso")
expect(locator).to_have_count(1)
expect(locator).not_to_have_text("lasso\n0")


@pytest.mark.usefixtures("bokeh_backend")
def test_stream_subcoordinate_y_range(serve_hv, points):
def cb(x_range, y_range):
return (
Curve(np.arange(100).cumsum(), vdims='y', label='A').opts(subcoordinate_y=True) *
Curve(-np.arange(100).cumsum(), vdims='y2', label='B').opts(subcoordinate_y=True)
)

stream = RangeXY()
dmap = DynamicMap(cb, streams=[stream]).opts(active_tools=['box_zoom'])

page = serve_hv(dmap)

hv_plot = page.locator('.bk-events')

expect(hv_plot).to_have_count(1)

bbox = hv_plot.bounding_box()
hv_plot.click()

page.mouse.move(bbox['x']+60, bbox['y']+60)
page.mouse.down()
page.mouse.move(bbox['x']+190, bbox['y']+190, steps=5)
page.mouse.up()

expected_xrange = (7.008849557522124, 63.95575221238938)
expected_yrange = (0.030612244897959183, 1.0918367346938775)
wait_until(lambda: stream.x_range == expected_xrange and stream.y_range == expected_yrange, page)

0 comments on commit 4db648d

Please sign in to comment.