diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 5e6011aafc..89541b5955 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -50,6 +50,7 @@ Lasso, MouseEnter, MouseLeave, + MultiAxisTap, PanEnd, PlotReset, PlotSize, @@ -781,6 +782,54 @@ def _process_out_of_bounds(self, value, start, end): return value +class MultiAxisTapCallback(TapCallback): + """ + Returns the mouse x/y-positions on tap event. + """ + + attributes = {'x': 'cb_obj.x', 'y': 'cb_obj.y'} + + def _process_msg(self, msg): + x_range = self.plot.handles.get('x_range') + y_range = self.plot.handles.get('y_range') + extra_x = list(self.plot.handles.get('extra_x_ranges', {}).values()) + extra_y = list(self.plot.handles.get('extra_y_ranges', {}).values()) + xaxis = self.plot.handles.get('xaxis') + yaxis = self.plot.handles.get('yaxis') + + # Compute x/y position relative to first axis + x, y = msg['x'], msg['y'] + x0, x1 = x_range.start, x_range.end + y0, y1 = y_range.start, y_range.end + if isinstance(xaxis, DatetimeAxis): + x = convert_timestamp(x) + if isinstance(x0, (float, int)): + x0 = convert_timestamp(x0) + if isinstance(x1, (float, int)): + x1 = convert_timestamp(x1) + if isinstance(yaxis, DatetimeAxis): + y = convert_timestamp(y) + if isinstance(y0, (float, int)): + y0 = convert_timestamp(y0) + if isinstance(y1, (float, int)): + y1 = convert_timestamp(y1) + xs, ys = {x_range.name: x}, {y_range.name: y} + xspan, yspan = x1 - x0, y1 - y0 + xfactor, yfactor = (x-x0) / xspan, (y-y0) / yspan + + # Use computed factors to compute x/y position on other axes + for values, factor, ranges, axis in ( + (xs, xfactor, extra_x, xaxis), + (ys, yfactor, extra_y, yaxis) + ): + for rng in ranges: + value = rng.start + (rng.end-rng.start) * factor + if isinstance(axis, DatetimeAxis) and isinstance(value, (float, int)): + value = convert_timestamp(value) + values[rng.name] = value + + return {'xs': xs, 'ys': ys} + class SingleTapCallback(TapCallback): """ @@ -1585,5 +1634,6 @@ def initialize(self, plot_id=None): FreehandDraw: FreehandDrawCallback, PolyDraw : PolyDrawCallback, PolyEdit : PolyEditCallback, - SelectMode : SelectModeCallback + SelectMode : SelectModeCallback, + MultiAxisTap: MultiAxisTapCallback }) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 47d5950c79..a8edb5a0f1 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -802,20 +802,27 @@ def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None, if dim_range: self._shared[shared_name] = True + # If we have a single dimension grab it so it can be set as the Range name + name = None + if dim: + name = dim.name + elif dims and len(dims) == 1: + name = dims[0].name + if self._shared.get(shared_name) and not dim: pass elif categorical: axis_type = 'auto' - dim_range = FactorRange() + dim_range = FactorRange(name=name) elif None in [v0, v1] or any( True if isinstance(el, (str, bytes)+util.cftime_types) else not util.isfinite(el) for el in [v0, v1] ): - dim_range = range_type() + dim_range = range_type(name=name) elif issubclass(range_type, FactorRange): - dim_range = range_type(name=dim.name if dim else None) + dim_range = range_type(name=name) else: - dim_range = range_type(start=v0, end=v1, name=dim.name if dim else None) + dim_range = range_type(start=v0, end=v1, name=name) if not dim_range.tags and specs is not None: dim_range.tags.append(specs) diff --git a/holoviews/streams.py b/holoviews/streams.py index c8c37656d3..c866f4447f 100644 --- a/holoviews/streams.py +++ b/holoviews/streams.py @@ -1358,6 +1358,18 @@ class Tap(PointerXY): Pointer position along the y-axis in data coordinates""") +class MultiAxisTap(LinkedStream): + """ + The x/y-positions of a tap or click in data coordinates. + """ + + xs = param.Dict(default=None, constant=True, doc=""" + Pointer positions along the x-axes in data coordinates""") + + ys = param.Dict(default=None, constant=True, doc=""" + Pointer positions along the y-axes in data coordinates""") + + class DoubleTap(PointerXY): """ The x/y-position of a double-tap or -click in data coordinates. diff --git a/holoviews/tests/ui/bokeh/test_callback.py b/holoviews/tests/ui/bokeh/test_callback.py index 725df28698..19cd5f0c43 100644 --- a/holoviews/tests/ui/bokeh/test_callback.py +++ b/holoviews/tests/ui/bokeh/test_callback.py @@ -1,11 +1,12 @@ import numpy as np +import pandas as pd import panel as pn import pytest import holoviews as hv from holoviews import Curve, DynamicMap, Scatter from holoviews.plotting.bokeh.util import bokeh34 -from holoviews.streams import BoundsX, BoundsXY, BoundsY, Lasso, RangeXY +from holoviews.streams import BoundsX, BoundsXY, BoundsY, Lasso, MultiAxisTap, RangeXY from .. import expect, wait_until @@ -164,6 +165,54 @@ def test_multi_axis_rangexy(serve_hv): ), page) +@pytest.mark.usefixtures("bokeh_backend") +def test_multi_axis_tap(serve_hv): + c1 = Curve(np.arange(10).cumsum(), vdims='y1') + c2 = Curve(np.arange(20).cumsum(), vdims='y2') + + overlay = (c1 * c2).opts(multi_y=True) + + s = MultiAxisTap(source=overlay) + + page = serve_hv(overlay) + + hv_plot = page.locator('.bk-events') + + expect(hv_plot).to_have_count(1) + + hv_plot.click() + + def test(): + assert s.xs == {'x': 11.560240963855422} + assert s.ys == {'y1': 18.642857142857146, 'y2': 78.71428571428572} + + wait_until(test, page) + + +@pytest.mark.usefixtures("bokeh_backend") +def test_multi_axis_tap_datetime(serve_hv): + c1 = Curve((pd.date_range('2024-01-01', '2024-01-10'), np.arange(10).cumsum()), vdims='y1') + c2 = Curve((pd.date_range('2024-01-01', '2024-01-20'), np.arange(20).cumsum()), vdims='y2') + + overlay = (c1 * c2).opts(multi_y=True) + + s = MultiAxisTap(source=overlay) + + page = serve_hv(overlay) + + hv_plot = page.locator('.bk-events') + + expect(hv_plot).to_have_count(1) + + hv_plot.click() + + def test(): + assert s.xs == {'x': np.datetime64('2024-01-12T13:26:44.819277')} + assert s.ys == {'y1': 18.13070539419087, 'y2': 76.551867219917} + + wait_until(test, page) + + @pytest.mark.usefixtures("bokeh_backend") def test_bind_trigger(serve_hv): # Regression test for https://github.com/holoviz/holoviews/issues/6013