Repository: holoviz/holoviews
Pull request #6257 (2024-06-04 18:34:49)

Add support for cticks
Closes https://github.com/holoviz/holoviews/issues/5723
```python
import numpy as np
import holoviews as hv

hv.extension("bokeh")

img = hv.Image(np.array([[0, 1], [2, 3]])).opts(
    cticks=[(1, "A"), (2, "B")], colorbar=True
)
img
```

<img width="681" alt="image" src="https://github.com/holoviz/holoviews/assets/15331990/3c605174-ad7e-4e17-967c-b8e217edc71e">


Didn't implement for matplotlib because it already has `cbar_ticks`

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6257?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
Attention: Patch coverage is `95.65217%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 88.48%. Comparing base [(`2ba3d78`)](https://app.codecov.io/gh/holoviz/holoviews/commit/2ba3d78f6c30340a607c3e63def5dfadc2c02f58?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`0b5935a`)](https://app.codecov.io/gh/holoviz/holoviews/commit/0b5935aacb87b9e225d5bc08f99cf929b5b2062a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).
> Report is 9 commits behind head on main.

| [Files](https://app.codecov.io/gh/holoviz/holoviews/pull/6257?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Patch % | Lines |
|---|---|---|
| [holoviews/plotting/bokeh/util.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6257?src=pr&el=tree&filepath=holoviews%2Fplotting%2Fbokeh%2Futil.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL3V0aWwucHk=) | 90.90% | [2 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6257?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6257      +/-   ##
==========================================
+ Coverage   88.39%   88.48%   +0.09%     
==========================================
  Files         323      323              
  Lines       67620    67668      +48     
==========================================
+ Hits        59770    59875     +105     
+ Misses       7850     7793      -57     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6257?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).


Patch:
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index e46be7150..48138bfa6 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -76,6 +76,7 @@ from .util import (
     get_axis_class,
     get_scale,
     get_tab_title,
+    get_ticker_axis_props,
     glyph_order,
     hold_policy,
     match_ax_type,
@@ -246,6 +247,16 @@ class ElementPlot(BokehPlot, GenericElementPlot):
     hover_mode = param.ObjectSelector(default='mouse', objects=['mouse', 'vline', 'hline'], doc="""
         The hover mode determines how the hover tool is activated.""")
 
+    xticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray, Ticker), default=None, doc="""
+        Ticks along x-axis specified as an integer, explicit list of
+        tick locations, or bokeh Ticker object. If set to None default
+        bokeh ticking behavior is applied.""")
+
+    yticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray, Ticker), default=None, doc="""
+        Ticks along y-axis specified as an integer, explicit list of
+        tick locations, or bokeh Ticker object. If set to None default
+        bokeh ticking behavior is applied.""")
+
     toolbar = param.ObjectSelector(default='right',
                                    objects=["above", "below",
                                             "left", "right", "disable", None],
@@ -1051,29 +1062,9 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             if rotation:
                 axis_props['major_label_orientation'] = np.radians(rotation)
             ticker = self.xticks if axis == 'x' else self.yticks
-            if isinstance(ticker, np.ndarray):
-                ticker = list(ticker)
-            if isinstance(ticker, Ticker):
-                axis_props['ticker'] = ticker
-            elif isinstance(ticker, int):
-                axis_props['ticker'] = BasicTicker(desired_num_ticks=ticker)
-            elif isinstance(ticker, (tuple, list)):
-                if all(isinstance(t, tuple) for t in ticker):
-                    ticks, labels = zip(*ticker)
-                    # Ensure floats which are integers are serialized as ints
-                    # because in JS the lookup fails otherwise
-                    ticks = [int(t) if isinstance(t, float) and t.is_integer() else t
-                             for t in ticks]
-                    labels = [l if isinstance(l, str) else str(l)
-                              for l in labels]
-                else:
-                    ticks, labels = ticker, None
-                if ticks and util.isdatetime(ticks[0]):
-                    ticks = [util.dt_to_int(tick, 'ms') for tick in ticks]
-                axis_props['ticker'] = FixedTicker(ticks=ticks)
-                if labels is not None:
-                    axis_props['major_label_overrides'] = dict(zip(ticks, labels))
-            elif self._subcoord_overlaid and axis == 'y':
+            if not (self._subcoord_overlaid and axis == 'y'):
+                axis_props.update(get_ticker_axis_props(ticker))
+            else:
                 ticks, labels = [], []
                 idx = 0
                 for el, sp in zip(self.current_frame, self.subplots.values()):
@@ -2470,6 +2461,11 @@ class ColorbarPlot(ElementPlot):
         #FFFFFFFF or a length 3 or length 4 tuple specifying values in
         the range 0-1 or a named HTML color.""")
 
+    cticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray, Ticker), default=None, doc="""
+        Ticks along colorbar-axis specified as an integer, explicit list of
+        tick locations, or bokeh Ticker object. If set to None default
+        bokeh ticking behavior is applied.""")
+
     logz = param.Boolean(default=False, doc="""
          Whether to apply log scaling to the z-axis.""")
 
@@ -2513,6 +2509,9 @@ class ColorbarPlot(ElementPlot):
         if self.cformatter is not None:
             self.colorbar_opts.update({'formatter': wrap_formatter(self.cformatter, 'c')})
 
+        if self.cticks is not None:
+            self.colorbar_opts.update(get_ticker_axis_props(self.cticks))
+
         for tk in ['cticks', 'ticks']:
             ticksize = self._fontsize(tk, common=False).get('fontsize')
             if ticksize is not None:
diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py
index 9b5a662b9..5d25b2349 100644
--- a/holoviews/plotting/bokeh/util.py
+++ b/holoviews/plotting/bokeh/util.py
@@ -39,12 +39,14 @@ from bokeh.models import (
 )
 from bokeh.models.formatters import PrintfTickFormatter, TickFormatter
 from bokeh.models.scales import CategoricalScale, LinearScale, LogScale
+from bokeh.models.tickers import BasicTicker, FixedTicker, Ticker
 from bokeh.models.widgets import DataTable, Div
 from bokeh.plotting import figure
 from bokeh.themes import built_in_themes
 from bokeh.themes.theme import Theme
 from packaging.version import Version
 
+from ...core import util
 from ...core.layout import Layout
 from ...core.ndmapping import NdMapping
 from ...core.overlay import NdOverlay, Overlay
@@ -1167,3 +1169,30 @@ def dtype_fix_hook(plot, element):
                 for k, v in data.items():
                     if hasattr(v, "dtype") and v.dtype.kind == "U":
                         data[k] = v.tolist()
+
+
+def get_ticker_axis_props(ticker):
+    axis_props = {}
+    if isinstance(ticker, np.ndarray):
+        ticker = list(ticker)
+    if isinstance(ticker, Ticker):
+        axis_props['ticker'] = ticker
+    elif isinstance(ticker, int):
+        axis_props['ticker'] = BasicTicker(desired_num_ticks=ticker)
+    elif isinstance(ticker, (tuple, list)):
+        if all(isinstance(t, tuple) for t in ticker):
+            ticks, labels = zip(*ticker)
+            # Ensure floats which are integers are serialized as ints
+            # because in JS the lookup fails otherwise
+            ticks = [int(t) if isinstance(t, float) and t.is_integer() else t
+                        for t in ticks]
+            labels = [l if isinstance(l, str) else str(l)
+                        for l in labels]
+        else:
+            ticks, labels = ticker, None
+        if ticks and util.isdatetime(ticks[0]):
+            ticks = [util.dt_to_int(tick, 'ms') for tick in ticks]
+        axis_props['ticker'] = FixedTicker(ticks=ticks)
+        if labels is not None:
+            axis_props['major_label_overrides'] = dict(zip(ticks, labels))
+    return axis_props
diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py
index 4bd69f675..cc9bdbc48 100644
--- a/holoviews/plotting/plot.py
+++ b/holoviews/plotting/plot.py
@@ -1132,15 +1132,13 @@ class GenericElementPlot(DimensionedPlot):
     yrotation = param.Integer(default=None, bounds=(0, 360), doc="""
         Rotation angle of the yticks.""")
 
-    xticks = param.Parameter(default=None, doc="""
+    xticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray), default=None, doc="""
         Ticks along x-axis specified as an integer, explicit list of
-        tick locations, or bokeh Ticker object. If set to None default
-        bokeh ticking behavior is applied.""")
+        tick locations. If set to None default ticking behavior is applied.""")
 
-    yticks = param.Parameter(default=None, doc="""
+    yticks = param.ClassSelector(class_=(int, list, tuple, np.ndarray), default=None, doc="""
         Ticks along y-axis specified as an integer, explicit list of
-        tick locations, or bokeh Ticker object. If set to None
-        default bokeh ticking behavior is applied.""")
+        tick locations. If set to None default ticking behavior is applied.""")
 
     # A dictionary mapping of the plot methods used to draw the
     # glyphs corresponding to the ElementPlot, can support two


Test patch:
diff --git a/holoviews/tests/plotting/bokeh/test_elementplot.py b/holoviews/tests/plotting/bokeh/test_elementplot.py
index 9cef2f3b6..af8e52b24 100644
--- a/holoviews/tests/plotting/bokeh/test_elementplot.py
+++ b/holoviews/tests/plotting/bokeh/test_elementplot.py
@@ -885,6 +885,51 @@ class TestColorbarPlot(LoggingComparisonTestCase, TestBokehPlot):
         self.assertEqual(cmapper.factors, ['0', '1', '2', '3'])
         self.assertEqual(cmapper.palette, ['blue', 'red', 'green', 'purple'])
 
+    def test_cticks_int(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=3, colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.desired_num_ticks == 3
+
+    def test_cticks_list(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=[1, 2], colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.ticks == [1, 2]
+
+    def test_cticks_tuple(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=(1, 2), colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.ticks == (1, 2)
+
+    def test_cticks_np_array(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=np.array([1, 2]), colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.ticks == [1, 2]
+
+    def test_cticks_labels(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(cticks=[(1, "A"), (2, "B")], colorbar=True)
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        assert colorbar.major_label_overrides == {1: "A", 2: "B"}
+        ticker = colorbar.ticker
+        assert ticker.ticks == [1, 2]
+
+    def test_cticks_ticker(self):
+        img = Image(np.array([[0, 1], [2, 3]])).opts(
+            cticks=FixedTicker(ticks=[0, 1]), colorbar=True
+        )
+        plot = bokeh_renderer.get_plot(img)
+        colorbar = plot.handles["colorbar"]
+        ticker = colorbar.ticker
+        assert ticker.ticks == [0, 1]
+
 
 class TestOverlayPlot(TestBokehPlot):