diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index aad33c7556..71384e9f0b 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -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)) @@ -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()): @@ -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: @@ -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 diff --git a/holoviews/plotting/bokeh/links.py b/holoviews/plotting/bokeh/links.py index 9aaea35fc5..f494ca52ab 100644 --- a/holoviews/plotting/bokeh/links.py +++ b/holoviews/plotting/bokeh/links.py @@ -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, @@ -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): """ @@ -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) diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index cc9bdbc489..99ddd489d4 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -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 @@ -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} @@ -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] diff --git a/holoviews/tests/plotting/bokeh/test_callbacks.py b/holoviews/tests/plotting/bokeh/test_callbacks.py index 1d6e523c8d..3aedf61145 100644 --- a/holoviews/tests/plotting/bokeh/test_callbacks.py +++ b/holoviews/tests/plotting/bokeh/test_callbacks.py @@ -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({}) == {} diff --git a/holoviews/tests/ui/bokeh/test_callback.py b/holoviews/tests/ui/bokeh/test_callback.py index 93eb11de3a..395dec132f 100644 --- a/holoviews/tests/ui/bokeh/test_callback.py +++ b/holoviews/tests/ui/bokeh/test_callback.py @@ -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)