diff --git a/hvplot/converter.py b/hvplot/converter.py index 0f90fc800..95a53adea 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -45,7 +45,7 @@ from holoviews.plotting.bokeh import OverlayPlot, colormap_generator from holoviews.plotting.util import process_cmap from holoviews.operation import histogram, apply_when -from holoviews.streams import Buffer, Pipe +from holoviews.streams import Buffer, Pipe, Tap, PointerXY from holoviews.util.transform import dim, lon_lat_to_easting_northing from pandas import DatetimeIndex, MultiIndex @@ -842,13 +842,22 @@ def __init__( if kind == 'errorbars': hover = False elif hover is None: - hover = not self.datashade + hover = True + if hover and not any( t for t in tools if isinstance(t, HoverTool) or t in ['hover', 'vline', 'hline'] ): if hover in {'vline', 'hline'}: plot_opts['hover_mode'] = hover - tools.append('hover') + self.hover_mode = hover + else: + self.hover_mode = 'mouse' + if not self.datashade: + tools.append('hover') + + self.hover = bool(hover) + self.hover_tooltips = hover_tooltips + self.hover_formatters = hover_formatters if 'hover' in tools: if hover_tooltips: plot_opts['hover_tooltips'] = hover_tooltips @@ -1760,7 +1769,7 @@ def method_wrapper(ds, x, y): return layers import_datashader() - from holoviews.operation.datashader import datashade, rasterize, dynspread + from holoviews.operation.datashader import datashade, rasterize, dynspread, inspect_points categorical, agg = self._process_categorical_datashader() if agg: @@ -1819,11 +1828,94 @@ def method_wrapper(ds, x, y): threshold=self.kwds.get('threshold', 0.5), ) + # a workaround to show hover info for datashaded points + if self.hover and self.datashade and self.kind == 'points': + if self.hover_mode != 'mouse': + param.main.param.warning( + f'Got unsupported hover_mode={self.hover_mode!r} for ' + f"datashaded points; reverting to 'mouse'." + ) + + stream = PointerXY + if len(self.data) > 10000: + stream = Tap + param.main.param.warning( + 'Hovering over datashaded points is slow for large datasets; ' + 'tap on the plot to see a hover tooltip over desired point.' + ) + + inspector = inspect_points.instance( + streams=[stream], transform=self._datashade_hover_transform + ) + processed *= inspector(processed).opts( + size=10, + alpha=0, + tools=['hover'], + hover_mode=self.hover_mode, + hover_tooltips=self.hover_tooltips, + hover_formatters=self.hover_formatters, + ) + opts = filter_opts(eltype, dict(self._plot_opts, **style), backend='bokeh') layers = self._apply_layers(processed).opts(eltype, **opts, backend='bokeh') layers = _transfer_opts_cur_backend(layers) return layers + def _datashade_hover_transform(self, df): + if not len(df): + return df + + # show at least the x and y columns + cols = self.hover_cols.copy() + if self.x not in cols: + cols.append(self.x) + if self.y not in cols: + cols.append(self.y) + + # handle aggregator, e.g. ds.sum('column') or ds.count_cat('column') + agg_col = None + agg_series_map = {} + if self.aggregator and not isinstance(self.aggregator, str) and self.aggregator.column: + agg_col = self.aggregator.column + agg_op = type(self.aggregator).__name__ + if hasattr(df, agg_op): # df.sum/df.count + agg_value = df.agg({agg_col: agg_op}) + elif agg_op == 'count_cat': + agg_value = df[agg_col].value_counts() + + if agg_col in cols: + cols.remove(agg_col) + else: + key = 'Count' + for i in range(1, 10): + if key in df.columns: + key = f'Count_{i}' + else: + break + agg_value = pd.Series([len(df)], index=[key]) + + # take the mean of numeric columns + num_series = df[cols].select_dtypes(include=['number']).mean() + if len(num_series): + agg_series_map['number_cols'] = num_series + + # take the first value of object columns + obj_series = df[cols].select_dtypes(exclude=['number']).iloc[0] + if len(obj_series): + agg_series_map['object_cols'] = obj_series + + # to preserve order of other columns, add this last + agg_series_map[agg_col] = agg_value + + # concat all series into a single dataframe which has one row + df_hover = pd.concat(agg_series_map.values()).to_frame().transpose() + + # remove index if it wasn't in the original dataset + if 'index' not in self.data.columns: + df_hover = df_hover.drop(columns=['index'], errors='ignore') + + return df_hover + def _resample_obj(self, operation, obj, opts): def exceeds_resample_when(plot): return len(plot) > self.resample_when diff --git a/hvplot/tests/testoperations.py b/hvplot/tests/testoperations.py index 3462d4a5c..6653fb52a 100644 --- a/hvplot/tests/testoperations.py +++ b/hvplot/tests/testoperations.py @@ -8,7 +8,7 @@ import pandas as pd import pytest -from holoviews import Store, render +from holoviews import Store, render, renderer from holoviews.element import Image, QuadMesh, Points from holoviews.core.spaces import DynamicMap from holoviews.core.overlay import Overlay @@ -156,11 +156,6 @@ def test_when_datashade_is_true_set_hover_to_false_by_default(self): opts = Store.lookup_options('bokeh', plot[()], 'plot').kwargs assert 'hover' not in opts.get('tools') - def test_when_datashade_is_true_hover_can_still_be_true(self): - plot = self.df.hvplot(x='x', y='y', datashade=True, hover=True) - opts = Store.lookup_options('bokeh', plot[()], 'plot').kwargs - assert 'hover' in opts.get('tools') - def test_xlim_affects_x_range(self): data = pd.DataFrame(np.random.randn(100).cumsum()) img = data.hvplot(xlim=(0, 20000), datashade=True, dynamic=False) @@ -324,6 +319,23 @@ def test_downsample_resample_when(self, kind, eltype): assert isinstance(element, eltype) assert len(element) == 0 + @parameterized.expand([(None,), (True,), ('vline',), ('hline',)]) + def test_include_inspect_point_hover(self, hover): + df = pd.DataFrame( + np.random.multivariate_normal((0, 0), [[0.1, 0.1], [0.1, 1.0]], (5000,)) + ).rename({0: 'x', 1: 'y'}, axis=1) + + p = df.hvplot.points(datashade=True, hover=hover) + assert renderer('bokeh').get_plot(p).name.startswith('Overlay') + + def test_include_inspect_point_no_hover(self): + df = pd.DataFrame( + np.random.multivariate_normal((0, 0), [[0.1, 0.1], [0.1, 1.0]], (5000,)) + ).rename({0: 'x', 1: 'y'}, axis=1) + + p = df.hvplot.points(datashade=True, hover=False) + assert renderer('bokeh').get_plot(p).name.startswith('RGB') + class TestChart2D(ComparisonTestCase): def setUp(self):