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

Add MultiAxisTap stream #6374

Merged
merged 15 commits into from
Sep 18, 2024
52 changes: 51 additions & 1 deletion holoviews/plotting/bokeh/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
Lasso,
MouseEnter,
MouseLeave,
MultiAxisTap,
PanEnd,
PlotReset,
PlotSize,
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -1585,5 +1634,6 @@ def initialize(self, plot_id=None):
FreehandDraw: FreehandDrawCallback,
PolyDraw : PolyDrawCallback,
PolyEdit : PolyEditCallback,
SelectMode : SelectModeCallback
SelectMode : SelectModeCallback,
MultiAxisTap: MultiAxisTapCallback
})
15 changes: 11 additions & 4 deletions holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions holoviews/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 50 additions & 1 deletion holoviews/tests/ui/bokeh/test_callback.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down