Repository: holoviz/holoviews
Pull request #5638 (2023-03-05 18:08:51)

Add support for extra hovertool variables in a Bokeh QuadMesh with 2-D coordinates (with tests)
When creating a Bokeh `QuadMesh` in the current version of HoloViews, you can supply additional data variables that will appear in the hovertool; however, this is only works for rectilinear geometries where the given coordinate arrays are 1-dimensional. When 2-dimensional coordinate arrays are given, any extra data variables are silently discarded and their values are displayed as "???" in the generated hovertool.

This pull request implements a simple fix that extends this functionality to support both 1-D and 2-D coordinates, which will allows `QuadMesh` plots to be created for non-rectilinear geometries without giving up the ability to add additional variables to the hovertool. 

Three new unit tests were also added to verify this change; one for testing that the existing functionality for 1-D coordinates is not affected, and the other two for testing the new functionality for 2-D coordinates.

See the screenshot below for a comparison of the hovertool behavior in a Jupyter notebook before and after implementing the fix:

<img width="675" alt="irregular_quadmesh_before_after" src="https://user-images.githubusercontent.com/31031841/222977047-39d8a78d-c465-4b8e-958d-55cb47279622.png">

codecov-commenter: # [Codecov](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
> Merging [#5638](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) (ab4197e) into [main](https://codecov.io/gh/holoviz/holoviews/commit/245d5cf07117a36480256cfbdcf553b733a21aa4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) (245d5cf) will **decrease** coverage by `4.07%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##             main    #5638      +/-   ##
==========================================
- Coverage   88.26%   84.20%   -4.07%     
==========================================
  Files         302      302              
  Lines       62517    62542      +25     
==========================================
- Hits        55181    52661    -2520     
- Misses       7336     9881    +2545     
```


| [Impacted Files](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Coverage Δ | |
|---|---|---|
| [holoviews/plotting/bokeh/raster.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL3Jhc3Rlci5weQ==) | `81.66% <100.00%> (+10.05%)` | :arrow_up: |
| [...oloviews/tests/plotting/bokeh/test\_quadmeshplot.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Rlc3RzL3Bsb3R0aW5nL2Jva2VoL3Rlc3RfcXVhZG1lc2hwbG90LnB5) | `100.00% <100.00%> (ø)` | |
| [holoviews/tests/operation/test\_datashader.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Rlc3RzL29wZXJhdGlvbi90ZXN0X2RhdGFzaGFkZXIucHk=) | `1.75% <0.00%> (-95.87%)` | :arrow_down: |
| [holoviews/operation/datashader.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL29wZXJhdGlvbi9kYXRhc2hhZGVyLnB5) | `0.70% <0.00%> (-83.29%)` | :arrow_down: |
| [holoviews/core/data/spatialpandas.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL2NvcmUvZGF0YS9zcGF0aWFscGFuZGFzLnB5) | `14.70% <0.00%> (-72.61%)` | :arrow_down: |
| [holoviews/tests/core/data/test\_spatialpandas.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Rlc3RzL2NvcmUvZGF0YS90ZXN0X3NwYXRpYWxwYW5kYXMucHk=) | `34.13% <0.00%> (-64.08%)` | :arrow_down: |
| [holoviews/tests/plotting/bokeh/test\_tiles.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Rlc3RzL3Bsb3R0aW5nL2Jva2VoL3Rlc3RfdGlsZXMucHk=) | `46.15% <0.00%> (-53.85%)` | :arrow_down: |
| [holoviews/plotting/bokeh/tiles.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL3RpbGVzLnB5) | `24.00% <0.00%> (-42.00%)` | :arrow_down: |
| [holoviews/core/data/spatialpandas\_dask.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL2NvcmUvZGF0YS9zcGF0aWFscGFuZGFzX2Rhc2sucHk=) | `50.79% <0.00%> (-31.75%)` | :arrow_down: |
| [holoviews/tests/core/test\_decollation.py](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Rlc3RzL2NvcmUvdGVzdF9kZWNvbGxhdGlvbi5weQ==) | `78.12% <0.00%> (-21.88%)` | :arrow_down: |
| ... and [39 more](https://codecov.io/gh/holoviz/holoviews/pull/5638?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz)

JRRudy1: It looks like the 3 failed checks are just a few tests getting skipped due to `datashader` being missing from the CI environment, but please let me know if I did something wrong. This is my first ever PR, and I am very open to advice from anyone reviewing it.
Hoxbro: The failing tests on Python 3.7 are unrelated to your changes. So don't worry about those :slightly_smiling_face: 

I will take an in-depth look at this PR tomorrow.

Patch:
diff --git a/holoviews/plotting/bokeh/raster.py b/holoviews/plotting/bokeh/raster.py
index dd316e679..b3cfda6fc 100644
--- a/holoviews/plotting/bokeh/raster.py
+++ b/holoviews/plotting/bokeh/raster.py
@@ -269,6 +269,7 @@ class QuadMeshPlot(ColorbarPlot):
         x, y = dimension_sanitizer(x.name), dimension_sanitizer(y.name)
 
         zdata = element.dimension_values(z, flat=False)
+        hover_data = {}
 
         if irregular:
             dims = element.kdims
@@ -291,11 +292,15 @@ class QuadMeshPlot(ColorbarPlot):
                         yc.append(ys.mean())
                 else:
                     mask.append(False)
+            mask = np.array(mask)
 
-            data = {'xs': XS, 'ys': YS, z.name: zvals[np.array(mask)]}
+            data = {'xs': XS, 'ys': YS, z.name: zvals[mask]}
             if 'hover' in self.handles:
-                data[x] = np.array(xc)
-                data[y] = np.array(yc)
+                if not self.static_source:
+                    hover_data = self._collect_hover_data(
+                            element, mask, irregular=True)
+                hover_data[x] = np.array(xc)
+                hover_data[y] = np.array(yc)
         else:
             xc, yc = (element.interface.coords(element, x, edges=True, ordered=True),
                       element.interface.coords(element, y, edges=True, ordered=True))
@@ -307,17 +312,34 @@ class QuadMeshPlot(ColorbarPlot):
                     'bottom': y0, 'top': y1}
 
             if 'hover' in self.handles and not self.static_source:
-                hover_dims = element.dimensions()[3:]
-                hover_data = [element.dimension_values(hover_dim, flat=False)
-                              for hover_dim in hover_dims]
-                for hdim, hdat in zip(hover_dims, hover_data):
-                    data[dimension_sanitizer(hdim.name)] = (hdat.flatten()
-                        if self.invert_axes else hdat.T.flatten())
-                data[x] = element.dimension_values(x)
-                data[y] = element.dimension_values(y)
+                hover_data = self._collect_hover_data(element)
+                hover_data[x] = element.dimension_values(x)
+                hover_data[y] = element.dimension_values(y)
+
+        data.update(hover_data)
 
         return data, mapping, style
 
+    def _collect_hover_data(self, element, mask=(), irregular=False):
+        """
+        Returns a dict mapping hover dimension names to flattened arrays.
+
+        Note that `Quad` glyphs are used when given 1-D coords but `Patches` are
+        used for "irregular" 2-D coords, and Bokeh inserts data into these glyphs
+        in the opposite order such that the relationship b/w the `invert_axes`
+        parameter and the need to transpose the arrays before flattening is
+        reversed.
+        """
+        transpose = self.invert_axes if irregular else not self.invert_axes
+
+        hover_dims = element.dimensions()[3:]
+        hover_vals = [element.dimension_values(hover_dim, flat=False)
+                      for hover_dim in hover_dims]
+        hover_data = {}
+        for hdim, hvals in zip(hover_dims, hover_vals):
+            hdat = hvals.T.flatten() if transpose else hvals.flatten()
+            hover_data[dimension_sanitizer(hdim.name)] = hdat[mask]
+        return hover_data
 
     def _init_glyph(self, plot, mapping, properties):
         """


Test patch:
diff --git a/holoviews/tests/plotting/bokeh/test_quadmeshplot.py b/holoviews/tests/plotting/bokeh/test_quadmeshplot.py
index 96a041abe..8a43c7fd9 100644
--- a/holoviews/tests/plotting/bokeh/test_quadmeshplot.py
+++ b/holoviews/tests/plotting/bokeh/test_quadmeshplot.py
@@ -65,3 +65,64 @@ class TestQuadMeshPlot(TestBokehPlot):
         plot = bokeh_renderer.get_plot(qmesh)
         source = plot.handles['source']
         self.assertEqual(source.data['z'], flattened)
+
+    def test_quadmesh_regular_centers(self):
+        X = [0.5, 1.5]
+        Y = [0.5, 1.5, 2.5]
+        Z = np.array([[1., 2., 3.], [4., 5., np.nan]]).T
+        LABELS = np.array([['0-0', '0-1', '0-2'], ['1-0', '1-1', '1-2']]).T
+        qmesh = QuadMesh((X, Y, Z, LABELS), vdims=['Value', 'Label'])
+        plot = bokeh_renderer.get_plot(qmesh.opts(tools=['hover']))
+        source = plot.handles['source']
+        expected = {
+                'left': [0., 0., 0., 1., 1., 1.],
+                'right': [1., 1., 1., 2., 2., 2.],
+                'Value': [ 1.,  2.,  3.,  4.,  5., np.nan],
+                'bottom': [0., 1., 2., 0., 1., 2.],
+                'top': [1., 2., 3., 1., 2., 3.],
+                'Label': ['0-0', '0-1', '0-2', '1-0', '1-1', '1-2'],
+                'x': [0.5, 0.5, 0.5, 1.5, 1.5, 1.5],
+                'y': [0.5, 1.5, 2.5, 0.5, 1.5, 2.5]}
+        self.assertEqual(source.data.keys(), expected.keys())
+        for key in expected:
+            self.assertEqual(list(source.data[key]), expected[key])
+
+    def test_quadmesh_irregular_centers(self):
+        X = [[0.5, 0.5, 0.5], [1.5, 1.5, 1.5]]
+        Y = [[0.5, 1.5, 2.5], [0.5, 1.5, 2.5]]
+        Z = np.array([[1., 2., 3.], [4., 5., np.nan]])
+        LABELS = np.array([['0-0', '0-1', '0-2'], ['1-0', '1-1', '1-2']])
+        qmesh = QuadMesh((X, Y, Z, LABELS), vdims=['Value', 'Label'])
+        plot = bokeh_renderer.get_plot(qmesh.opts(tools=['hover']))
+        source = plot.handles['source']
+        expected = {'xs': [[0., 1., 1., 0.], [0., 1., 1., 0.], [0., 1., 1., 0.],
+                           [1., 2., 2., 1.], [1., 2., 2., 1.]],
+                    'ys': [[0., 0., 1., 1.], [1., 1., 2., 2.], [2., 2., 3., 3.],
+                           [0., 0., 1., 1.], [1., 1., 2., 2.]],
+                    'Value': [1., 2., 3., 4., 5.],
+                    'Label': ['0-0', '0-1', '0-2', '1-0', '1-1'],
+                    'x': [0.5, 0.5, 0.5, 1.5, 1.5],
+                    'y': [0.5, 1.5, 2.5, 0.5, 1.5]}
+        self.assertEqual(source.data.keys(), expected.keys())
+        for key in expected:
+            self.assertEqual(list(source.data[key]), expected[key])
+
+    def test_quadmesh_irregular_edges(self):
+        X = [[0., 0., 0., 0.], [1., 1., 1., 1.], [2., 2., 2., 2.]]
+        Y = [[0., 1., 2., 3.], [0., 1., 2., 3.], [0., 1., 2., 3.]]
+        Z = np.array([[1., 2., 3.], [4., 5., np.nan]])
+        LABELS = np.array([['0-0', '0-1', '0-2'], ['1-0', '1-1', '1-2']])
+        qmesh = QuadMesh((X, Y, Z, LABELS), vdims=['Value', 'Label'])
+        plot = bokeh_renderer.get_plot(qmesh.opts(tools=['hover']))
+        source = plot.handles['source']
+        expected = {'xs': [[0., 1., 1., 0.], [0., 1., 1., 0.], [0., 1., 1., 0.],
+                           [1., 2., 2., 1.], [1., 2., 2., 1.]],
+                    'ys': [[0., 0., 1., 1.], [1., 1., 2., 2.], [2., 2., 3., 3.],
+                           [0., 0., 1., 1.], [1., 1., 2., 2.]],
+                    'Value': [1., 2., 3., 4., 5.],
+                    'Label': ['0-0', '0-1', '0-2', '1-0', '1-1'],
+                    'x': [0.5, 0.5, 0.5, 1.5, 1.5],
+                    'y': [0.5, 1.5, 2.5, 0.5, 1.5]}
+        self.assertEqual(source.data.keys(), expected.keys())
+        for key in expected:
+            self.assertEqual(list(source.data[key]), expected[key])