Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure Range streams and RangeToolLink respect subcoordinate axis range #6256

Merged
merged 14 commits into from
Jun 7, 2024
9 changes: 7 additions & 2 deletions holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,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 @@ -1064,7 +1067,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 @@ -2042,6 +2045,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 @@ -2839,6 +2843,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
54 changes: 33 additions & 21 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,37 @@ 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.
philippjfr marked this conversation as resolved.
Show resolved Hide resolved
"""
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 +148,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
philippjfr marked this conversation as resolved.
Show resolved Hide resolved
(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)