diff --git a/doc/ref/plotting_options/color_colormap.ipynb b/doc/ref/plotting_options/color_colormap.ipynb index 17cdb64a4..3ac4c492b 100644 --- a/doc/ref/plotting_options/color_colormap.ipynb +++ b/doc/ref/plotting_options/color_colormap.ipynb @@ -130,6 +130,40 @@ ":::" ] }, + { + "cell_type": "markdown", + "id": "be669a46", + "metadata": {}, + "source": [ + "::: {admonition} Custom Color Mapping: Best Practice\n", + ":class: tip\n", + "\n", + "\n", + "When you want to map categorical values to specific colors, use `color='column_name'` with `cmap={...}` instead of passing a pre-mapped Series:\n", + "\n", + "**✅ Recommended:**\n", + "```python\n", + "df.hvplot.scatter(\n", + " x='x', y='y',\n", + " color='species',\n", + " cmap={'Adelie': 'blue', 'Gentoo': 'yellow', 'Chinstrap': 'red'}\n", + ")\n", + "# Hover shows: x, y, species: Adelie\n", + "```\n", + "\n", + "**❌ Not recommended:**\n", + "```python\n", + "df.hvplot.scatter(\n", + " x='x', y='y',\n", + " color=df['species'].map({'Adelie': 'blue', ...})\n", + ")\n", + "# Hover shows: x, y only\n", + "```\n", + "\n", + "Using `color='column_name'` with `cmap` keeps your original data visible in hover tooltips and provides a cleaner, more maintainable API.\n", + ":::" + ] + }, { "cell_type": "markdown", "id": "7a03fc23-d338-44df-9777-9f06ad1096eb", diff --git a/hvplot/converter.py b/hvplot/converter.py index 50ff45987..00863f39c 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -2411,6 +2411,14 @@ def single_chart(self, element, x, y, data=None): ys += [self.kwds['yerr1']] kdims, vdims = self._get_dimensions([x], ys) + # Automatically exclude internal style columns from hover tooltips + # if hover_tooltips was not explicitly set by the user + internal_cols = {'_color', '_size'} + if 'hover_tooltips' not in self._plot_opts and any(v in internal_cols for v in vdims): + hover_dims = [d for d in kdims + vdims if d not in internal_cols] + if hover_dims: + cur_opts[element.name]['hover_tooltips'] = [(d, f'@{{{d}}}') for d in hover_dims] + if self.by: if element is Bars and not self.subplots: if not support_index(data) and any(y in self.indexes for y in ys): diff --git a/hvplot/tests/testcharts.py b/hvplot/tests/testcharts.py index 90794cb6f..9e82c8ef8 100644 --- a/hvplot/tests/testcharts.py +++ b/hvplot/tests/testcharts.py @@ -90,6 +90,81 @@ def test_xarray_dataset_with_attrs(self): assert render(ndoverlay, 'bokeh').xaxis.axis_label == 'time (s)' + def test_color_series_excluded_from_default_hover(self): + color_series = self.cat_df['category'].map({'A': 'red', 'B': 'blue'}) + plot = self.cat_df.hvplot.scatter(x='x', y='y', color=color_series) + + # Check hover_tooltips option + opts = Store.lookup_options('bokeh', plot, 'plot') + hover_tooltips = opts.kwargs.get('hover_tooltips') + + # hover_tooltips should be set and not include _color + assert hover_tooltips is not None + tooltip_dims = [tt[0] if isinstance(tt, tuple) else tt for tt in hover_tooltips] + assert '_color' not in tooltip_dims + assert 'x' in tooltip_dims + assert 'y' in tooltip_dims + + def test_size_series_excluded_from_default_hover(self): + size_series = self.cat_df['y'] / 10 + plot = self.cat_df.hvplot.scatter(x='x', y='y', s=size_series) + + assert '_size' in plot.data.columns + + opts = Store.lookup_options('bokeh', plot, 'plot') + hover_tooltips = opts.kwargs.get('hover_tooltips') + + # hover_tooltips should be set and not include _size + assert hover_tooltips is not None + tooltip_dims = [tt[0] if isinstance(tt, tuple) else tt for tt in hover_tooltips] + assert '_size' not in tooltip_dims + assert 'x' in tooltip_dims + assert 'y' in tooltip_dims + + def test_explicit_hover_tooltips_respected_with_internal_columns(self): + color_series = self.cat_df['category'].map({'A': 'red', 'B': 'blue'}) + plot = self.cat_df.hvplot.scatter( + x='x', y='y', color=color_series, hover_tooltips=[('x', '@x'), ('color', '@_color')] + ) + + # Explicit hover_tooltips should be used + opts = Store.lookup_options('bokeh', plot, 'plot') + hover_tooltips = opts.kwargs.get('hover_tooltips') + assert hover_tooltips == [('x', '@x'), ('color', '@_color')] + + def test_color_column_name_shown_in_hover(self): + plot = self.cat_df.hvplot.scatter(x='x', y='y', color='category') + + assert 'category' in [d.name for d in plot.vdims] + assert '_color' not in plot.data.columns + + def test_color_with_cmap_dict_shown_in_hover(self): + plot = self.cat_df.hvplot.scatter( + x='x', y='y', color='category', cmap={'A': 'red', 'B': 'blue'} + ) + + assert 'category' in [d.name for d in plot.vdims] + assert '_color' not in plot.data.columns + + def test_both_color_and_size_series_excluded_from_hover(self): + color_series = self.cat_df['category'].map({'A': 'red', 'B': 'blue'}) + size_series = self.cat_df['y'] / 10 + plot = self.cat_df.hvplot.scatter(x='x', y='y', color=color_series, s=size_series) + + # Both should be in data + assert '_color' in plot.data.columns + assert '_size' in plot.data.columns + + opts = Store.lookup_options('bokeh', plot, 'plot') + hover_tooltips = opts.kwargs.get('hover_tooltips') + + assert hover_tooltips is not None + tooltip_dims = [tt[0] if isinstance(tt, tuple) else tt for tt in hover_tooltips] + assert '_color' not in tooltip_dims + assert '_size' not in tooltip_dims + assert 'x' in tooltip_dims + assert 'y' in tooltip_dims + class TestChart2DDask(TestChart2D): def setUp(self): @@ -123,6 +198,19 @@ def test_2d_set_hover_cols_to_all(self, kind, element): plot, element(self.cat_df.reset_index(), ['x', 'y'], ['index', 'category']) ) + # Skip Series.map() tests as they don't work with Dask + def test_color_series_excluded_from_default_hover(self): + raise SkipTest('Series.map() not supported with Dask DataFrames') + + def test_size_series_excluded_from_default_hover(self): + raise SkipTest('Series.map() not supported with Dask DataFrames') + + def test_explicit_hover_tooltips_respected_with_internal_columns(self): + raise SkipTest('Series.map() not supported with Dask DataFrames') + + def test_both_color_and_size_series_excluded_from_hover(self): + raise SkipTest('Series.map() not supported with Dask DataFrames') + class TestChart1D(ComparisonTestCase): def setUp(self):