Repository: holoviz/holoviews
Pull request #6575 (2025-05-14 16:05:25)

enh(bokeh): Add log support for Histogram
Resolve #2591 

Still POC, needs to be cleaned up, tested, etc. 

- [x] `invert_axes`, `invert_xaxis`, `invert_yaxis` implementation
- [x] Look into the limit from the mpl backend (not related, but noticed it)
- [x] Add / fix tests

This dynamically updates the bottom of the rectangle, never to run out.


![image](https://github.com/user-attachments/assets/0db94c17-7462-43b6-9eca-d5344d86ee1b)



``` python
import holoviews as hv
hv.extension("bokeh")

null = 0
a = hv.Histogram(([1, 3, 6, 9], [1, 10, null, 100])).opts(logy=True)
b = hv.Histogram(([1, 3, 6, 9], [1, 10, null, 100])).opts(logy=True, invert_yaxis=True)
c = hv.Histogram(([1, 3, 6, 9], [1, 10, null, 100])).opts(logx=True, invert_axes=True)
d = hv.Histogram(([1, 3, 6, 9], [1, 10, null, 100])).opts(logx=True, invert_axes=True, invert_xaxis=True)

(a + b + c + d).cols(2).opts(shared_axes=False)
```


https://github.com/user-attachments/assets/dd95f32c-3e42-40a5-932b-324a54f21b0c




``` python
import hvplot.pandas
import pandas as pd

pd.Series(range(1, 100)).hvplot.hist(logy=True)
```

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6575?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 `92.30769%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 88.81%. Comparing base [(`5858c4c`)](https://app.codecov.io/gh/holoviz/holoviews/commit/5858c4c0510b0923490f2bbee886948b1b8a6679?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`b7e651c`)](https://app.codecov.io/gh/holoviz/holoviews/commit/b7e651c8e2ef0c4d406b7a52c19bd141d16e7707?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).

| [Files with missing lines](https://app.codecov.io/gh/holoviz/holoviews/pull/6575?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/chart.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6575?src=pr&el=tree&filepath=holoviews%2Fplotting%2Fbokeh%2Fchart.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL2NoYXJ0LnB5) | 92.30% | [1 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6575?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    #6575      +/-   ##
==========================================
- Coverage   88.81%   88.81%   -0.01%     
==========================================
  Files         326      326              
  Lines       69465    69475      +10     
==========================================
+ Hits        61697    61705       +8     
- Misses       7768     7770       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6575?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).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/.github/workflows/conventional_commits.yaml b/.github/workflows/conventional_commits.yaml
index bd04a4141..1d0de5590 100644
--- a/.github/workflows/conventional_commits.yaml
+++ b/.github/workflows/conventional_commits.yaml
@@ -42,3 +42,8 @@ jobs:
             plotly
           ignoreLabels: |
             ignore-semantic-pull-request
+          subjectPatternError: |
+            The subject "{subject}" found in the pull request title "{title}"
+            didn't match the configured pattern. Please ensure that the subject
+            doesn't start with an uppercase character. Also make sure that the
+            type ends with a colol, e.g. `docs:`
diff --git a/.gitignore b/.gitignore
index ae3111fef..39b8da709 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@
 /doc/_build
 /doc/Reference_Manual/*
 !/doc/Reference_Manual/index.rst
+builtdocs/
 .ipynb_checkpoints
 .coverage
 .pytest_cache
diff --git a/doc/conf.py b/doc/conf.py
index 13ea6c02d..2e505e942 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -63,8 +63,16 @@ extensions += [
     'nbsite.gallery',
     'sphinx_copybutton',
     'nbsite.analytics',
+    'numpydoc',
 ]
 
+intersphinx_mapping = {
+    'panel':    ('https://panel.holoviz.org/', None),
+}
+
+numpydoc_xref_param_type = True
+numpydoc_xref_type       = True
+
 myst_enable_extensions = ["colon_fence", "deflist"]
 numpydoc_show_inherited_class_members = False
 numpydoc_class_members_toctree = False
diff --git a/examples/reference/elements/bokeh/Dendrogram.ipynb b/examples/reference/elements/bokeh/Dendrogram.ipynb
index ae759b2a6..b71737c63 100644
--- a/examples/reference/elements/bokeh/Dendrogram.ipynb
+++ b/examples/reference/elements/bokeh/Dendrogram.ipynb
@@ -19,8 +19,6 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import random\n",
-    "\n",
     "import numpy as np\n",
     "import pandas as pd\n",
     "\n",
@@ -77,12 +75,25 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "df = pd.DataFrame(\n",
-    "    [(i, chr(65 + j), random.random()) for j in range(10) for i in range(5)],\n",
-    "    columns=[\"z\", \"x\", \"y\"],\n",
+    "data = [\n",
+    "    [7, 8, 0, 0, 0, 0],\n",
+    "    [8, 6, 0, 0, 0, 0],\n",
+    "    [0, 0, 9, 8, 2, 7],\n",
+    "    [0, 0, 0, 1, 8, 2],\n",
+    "    [0, 7, 0, 7, 0, 0],\n",
+    "]\n",
+    "df = pd.DataFrame([\n",
+    "    {\"cluster\": f\"clust {i}\", \"gene\": f\"gene {j}\", \"value\": data[i][j]}\n",
+    "    for j in range(6)\n",
+    "    for i in range(5)\n",
+    "])\n",
+    "\n",
+    "# Setting the colors to the normalized value and only have a size for non-zero values\n",
+    "opts = dict(\n",
+    "    color=hv.dim(\"value\").norm(), size=(hv.dim(\"value\") != 0) * 30, cmap=\"Reds\", tools=[\"hover\"]\n",
     ")\n",
-    "heatmap = hv.HeatMap(df)\n",
-    "heatmap"
+    "plot = hv.Points(df, kdims=[\"gene\", \"cluster\"]).opts(**opts)\n",
+    "plot"
    ]
   },
   {
@@ -93,7 +104,7 @@
    "source": [
     "from holoviews.operation import dendrogram\n",
     "\n",
-    "hv.operation.dendrogram(heatmap, adjoint_dims=[\"x\", \"z\"], main_dim=\"y\")"
+    "hv.operation.dendrogram(plot, adjoint_dims=[\"cluster\"], main_dim=\"value\")"
    ]
   }
  ],
diff --git a/examples/reference/elements/bokeh/Points.ipynb b/examples/reference/elements/bokeh/Points.ipynb
index 48b85a959..ae7f041e6 100644
--- a/examples/reference/elements/bokeh/Points.ipynb
+++ b/examples/reference/elements/bokeh/Points.ipynb
@@ -155,6 +155,30 @@
     "\n",
     "For full documentation and the available style and plot options, use ``hv.help(hv.Points).``"
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Scale by radius\n",
+    "If you want to scale the size of points based on a radius, this ensures that the visual scale remains consistent when you zoom in or out—since radius is specified in data coordinates, not screen pixels.\n",
+    "\n",
+    "Below is an example using `hv.Points`, where the radius is determined by normalizing the `zs` values:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x = np.arange(5)\n",
+    "y = np.arange(4)\n",
+    "z = np.arange(20).reshape(4, 5)\n",
+    "\n",
+    "radius_dim = dim(\"z\").norm() / 2\n",
+    "hv.Points((x, y, z), kdims=[\"x\", \"y\"], vdims=\"z\").opts(radius=radius_dim)"
+   ]
   }
  ],
  "metadata": {
diff --git a/examples/user_guide/Customizing_Plots.ipynb b/examples/user_guide/Customizing_Plots.ipynb
index da0293bf8..337af4d76 100644
--- a/examples/user_guide/Customizing_Plots.ipynb
+++ b/examples/user_guide/Customizing_Plots.ipynb
@@ -41,7 +41,7 @@
     "        - [Logarithmic axes](#log-axes)\n",
     "        - [Datetime axes](#datetime-axes)\n",
     "        - [Categorical axes](#categorical-axes)\n",
-    "    - [Axis position](#axis-position): Positioning and hiding axes\n",
+    "    - [Axis positions](#axis-positions): Positioning and hiding axes\n",
     "    - [Inverting axes](#inverting-axes): Flipping the x-/y-axes and inverting an axis\n",
     "    - [Axis labels](#axis-labels): Setting axis labels using dimensions and options\n",
     "    - [Axis ranges](#axis-ranges): Controlling axes ranges using dimensions, padding and options\n",
diff --git a/holoviews/core/data/pandas.py b/holoviews/core/data/pandas.py
index fdea717ba..2f2ee32a2 100644
--- a/holoviews/core/data/pandas.py
+++ b/holoviews/core/data/pandas.py
@@ -44,7 +44,7 @@ class PandasInterface(Interface, PandasAPI):
     def applies(cls, obj):
         if not cls.loaded():
             return False
-        return isinstance(obj, (pd.DataFrame, pd.Series))
+        return type(obj) is pd.DataFrame
 
     @classmethod
     def dimension_type(cls, dataset, dim):
@@ -426,7 +426,7 @@ class PandasInterface(Interface, PandasAPI):
                     index_sel = {}
             column_sel = {k: v for k, v in selection.items() if k not in index_sel}
             if column_sel:
-                selection_mask = cls.select_mask(dataset, column_sel)
+                selection_mask = cls.select_mask(dataset.clone(data=df), column_sel)
 
         indexed = cls.indexed(dataset, selection)
         if isinstance(selection_mask, pd.Series):
diff --git a/holoviews/core/util/__init__.py b/holoviews/core/util/__init__.py
index db5bcf6b0..755d4c80f 100644
--- a/holoviews/core/util/__init__.py
+++ b/holoviews/core/util/__init__.py
@@ -31,6 +31,7 @@ from .dependencies import (  # noqa: F401
     PANDAS_VERSION,
     PARAM_VERSION,
     VersionError,
+    _LazyModule,
 )
 from .types import (
     arraylike_types,
@@ -43,6 +44,14 @@ from .types import (
     timedelta_types,
 )
 
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    import pandas as pd
+    import polars as pl
+else:
+    pd = _LazyModule("pandas", bool_use_sys_modules=True)
+    pl = _LazyModule("polars", bool_use_sys_modules=True)
+
 # Python 2 builtins
 basestring = str
 long = int
@@ -800,10 +809,21 @@ label_sanitizer = sanitize_identifier_fn.instance()
 dimension_sanitizer = sanitize_identifier_fn.instance(capitalize=False)
 
 def isscalar(val):
-    """Value is scalar or None
+    """Value is scalar or nullable
 
     """
-    return val is None or np.isscalar(val) or isinstance(val, datetime_types)
+    return is_null_or_na_scalar(val) or np.isscalar(val) or isinstance(val, datetime_types)
+
+
+def is_null_or_na_scalar(val):
+    if hasattr(val, "__len__"):
+        return False
+    return bool(
+        val is None
+        or (pd and (val is pd.NA or val is pd.NaT))
+        or (pl and val is pl.Null)
+        or (np.isscalar(val) and np.isnan(val))
+    )
 
 
 def isnumeric(val):
@@ -1088,18 +1108,20 @@ def max_extents(extents, zrange=False):
         for lidx, uidx in inds:
             lower = [v for v in arr[lidx] if v is not None and not is_nan(v)]
             upper = [v for v in arr[uidx] if v is not None and not is_nan(v)]
-            if lower and isinstance(lower[0], datetime_types):
-                extents[lidx] = np.min(lower)
-            elif any(isinstance(l, str) for l in lower):
-                extents[lidx] = np.sort(lower)[0]
-            elif lower:
-                extents[lidx] = np.nanmin(lower)
-            if upper and isinstance(upper[0], datetime_types):
-                extents[uidx] = np.max(upper)
-            elif any(isinstance(u, str) for u in upper):
-                extents[uidx] = np.sort(upper)[-1]
-            elif upper:
-                extents[uidx] = np.nanmax(upper)
+            if lower:
+                if any(isinstance(l, str) for l in lower):
+                    extents[lidx] = sorted(lower, key=str)[0]
+                elif isinstance(lower[0], datetime_types):
+                    extents[lidx] = np.min(lower)
+                else:
+                    extents[lidx] = np.nanmin(lower)
+            if upper:
+                if any(isinstance(u, str) for u in upper):
+                    extents[uidx] = sorted(upper, key=str)[-1]
+                elif isinstance(upper[0], datetime_types):
+                    extents[uidx] = np.max(upper)
+                else:
+                    extents[uidx] = np.nanmax(upper)
     return tuple(extents)
 
 
diff --git a/holoviews/core/util/dependencies.py b/holoviews/core/util/dependencies.py
index 29350d7de..b4575e5ed 100644
--- a/holoviews/core/util/dependencies.py
+++ b/holoviews/core/util/dependencies.py
@@ -18,6 +18,8 @@ class VersionError(Exception):
 
 @lru_cache
 def _is_installed(module_name):
+    # So we don't accidentally import it
+    module_name, *_ = module_name.split(".")
     return find_spec(module_name) is not None
 
 
diff --git a/holoviews/core/util/types.py b/holoviews/core/util/types.py
index 2d39e3f72..b64d53eb2 100644
--- a/holoviews/core/util/types.py
+++ b/holoviews/core/util/types.py
@@ -74,8 +74,8 @@ def datetime_types():
     yield from (dt.datetime, dt.date, dt.time)
     if np:
         yield np.datetime64
-    yield from pandas_datetime_types()
-    yield from cftime_types()
+    yield from pandas_datetime_types
+    yield from cftime_types
 
 
 @gen_types
@@ -83,7 +83,7 @@ def timedelta_types():
     yield dt.timedelta
     if np:
         yield np.timedelta64
-    yield from pandas_timedelta_types()
+    yield from pandas_timedelta_types
 
 
 @gen_types
diff --git a/holoviews/element/annotation.py b/holoviews/element/annotation.py
index e3497cab3..f59be5728 100644
--- a/holoviews/element/annotation.py
+++ b/holoviews/element/annotation.py
@@ -128,7 +128,7 @@ class VLine(Annotation):
 
     group = param.String(default='VLine', constant=True)
 
-    x = param.ClassSelector(default=0, class_=(Number, *datetime_types), doc="""
+    x = param.ClassSelector(default=0, class_=(Number, datetime_types), doc="""
        The x-position of the VLine which make be numeric or a timestamp.""")
 
     __pos_params = ['x']
@@ -170,7 +170,7 @@ class HLine(Annotation):
 
     group = param.String(default='HLine', constant=True)
 
-    y = param.ClassSelector(default=0, class_=(Number, *datetime_types), doc="""
+    y = param.ClassSelector(default=0, class_=(Number, datetime_types), doc="""
        The y-position of the HLine which make be numeric or a timestamp.""")
 
     __pos_params = ['y']
@@ -250,10 +250,10 @@ class VSpan(Annotation):
 
     group = param.String(default='VSpan', constant=True)
 
-    x1 = param.ClassSelector(default=0, class_=(Number, *datetime_types), allow_None=True, doc="""
+    x1 = param.ClassSelector(default=0, class_=(Number, datetime_types), allow_None=True, doc="""
        The start x-position of the VSpan which must be numeric or a timestamp.""")
 
-    x2 = param.ClassSelector(default=0, class_=(Number, *datetime_types), allow_None=True, doc="""
+    x2 = param.ClassSelector(default=0, class_=(Number, datetime_types), allow_None=True, doc="""
        The end x-position of the VSpan which must be numeric or a timestamp.""")
 
     __pos_params = ['x1', 'x2']
@@ -293,10 +293,10 @@ class HSpan(Annotation):
 
     group = param.String(default='HSpan', constant=True)
 
-    y1 = param.ClassSelector(default=0, class_=(Number, *datetime_types), allow_None=True, doc="""
+    y1 = param.ClassSelector(default=0, class_=(Number, datetime_types), allow_None=True, doc="""
        The start y-position of the VSpan which must be numeric or a timestamp.""")
 
-    y2 = param.ClassSelector(default=0, class_=(Number, *datetime_types), allow_None=True, doc="""
+    y2 = param.ClassSelector(default=0, class_=(Number, datetime_types), allow_None=True, doc="""
        The end y-position of the VSpan which must be numeric or a timestamp.""")
 
     __pos_params = ['y1', 'y2']
@@ -408,10 +408,10 @@ class Arrow(Annotation):
 
     """
 
-    x = param.ClassSelector(default=0, class_=(Number, *datetime_types), doc="""
+    x = param.ClassSelector(default=0, class_=(Number, datetime_types), doc="""
        The x-position of the arrow which make be numeric or a timestamp.""")
 
-    y = param.ClassSelector(default=0, class_=(Number, *datetime_types), doc="""
+    y = param.ClassSelector(default=0, class_=(Number, datetime_types), doc="""
        The y-position of the arrow which make be numeric or a timestamp.""")
 
     text = param.String(default='', doc="Text associated with the arrow.")
@@ -484,10 +484,10 @@ class Text(Annotation):
 
     """
 
-    x = param.ClassSelector(default=0, class_=(Number, str, *datetime_types), doc="""
+    x = param.ClassSelector(default=0, class_=(Number, str, datetime_types), doc="""
        The x-position of the arrow which make be numeric or a timestamp.""")
 
-    y = param.ClassSelector(default=0, class_=(Number, str, *datetime_types), doc="""
+    y = param.ClassSelector(default=0, class_=(Number, str, datetime_types), doc="""
        The y-position of the arrow which make be numeric or a timestamp.""")
 
     text = param.String(default='', doc="The text to be displayed.")
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index d1fe2d1b7..46106ecb9 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -1779,8 +1779,11 @@ class SpreadingOperation(LinkableOperation):
                 for idx, k, in enumerate("RGBA"):
                     new_data[k].data = img[:, :, idx]
             elif isinstance(element, ImageStack):
-                for k in map(str, element.vdims):
-                    new_data[k].data = array.sel(z=k)
+                if len(element.vdims) == 1:
+                    new_data[element.vdims[0].name].data = array
+                else:
+                    for k in map(str, element.vdims):
+                        new_data[k].data = array.sel(z=k)
             elif isinstance(element, Image):
                 new_data[element.vdims[0].name].data = array
             else:
@@ -1789,12 +1792,7 @@ class SpreadingOperation(LinkableOperation):
 
             for k, v in sel_data.items():
                 new_data[k].data = v
-
-            # TODO: Investigate why this does not work
-            # element = element.clone(data=new_data, kdims=element.vdims.copy(), vdims=element.vdims.copy())
-            element = element.clone()
-            element.data = new_data
-            return element
+            return element.clone(data=new_data)
 
         kwargs = {}
         if isinstance(element, RGB):
diff --git a/holoviews/operation/element.py b/holoviews/operation/element.py
index 24f68b336..f4857e2aa 100644
--- a/holoviews/operation/element.py
+++ b/holoviews/operation/element.py
@@ -1245,10 +1245,36 @@ class dendrogram(Operation):
          between successive leaves is minimal. This results in a more intuitive
          tree structure when the data are visualized. defaults to False,
          because this algorithm can be slow, particularly on large datasets.
-         See for more information:
+         For more information:
          https://docs.scipy.org/doc/scipy/reference/generated/scipy.cluster.hierarchy.linkage.html#scipy.cluster.hierarchy.linkage
          """)
 
+    linkage_method = param.Selector(
+        default="complete",
+        objects=["single", "complete", "average", "centroid", "median", "ward", "weighted"],
+        doc="""
+        The linkage algorithm to use. For more information:
+        https://docs.scipy.org/doc/scipy/reference/generated/scipy.cluster.hierarchy.linkage.html#scipy.cluster.hierarchy.linkage
+        """
+
+    )
+    linkage_metric = param.Selector(
+        default="correlation",
+        objects=[
+            "braycurtis", "canberra", "chebyshev", "cityblock",
+            "correlation", "cosine", "dice", "euclidean", "hamming",
+            "jaccard", "jensenshannon", "kulczynski1",
+            "mahalanobis", "matching", "minkowski", "rogerstanimoto",
+            "russellrao", "seuclidean", "sokalmichener", "sokalsneath",
+            "sqeuclidean", "yule"
+        ],
+        doc="""
+        The distance metric to use in the case that y is a collection of observation vectors; ignored otherwise.
+        For more information:
+        https://docs.scipy.org/doc/scipy/reference/generated/scipy.cluster.hierarchy.linkage.html#scipy.cluster.hierarchy.linkage
+        """
+    )
+
     def _compute_linkage(self, dataset, dim, vdim):
         try:
             from scipy.cluster.hierarchy import dendrogram, linkage
@@ -1259,10 +1285,15 @@ class dendrogram(Operation):
         for k, v in dataset.groupby(dim, container_type=list, group_type=Dataset):
             labels.append(k)
             arrays.append(v.dimension_values(vdim))
+
         X = np.vstack(arrays)
-        Z = linkage(X, optimal_ordering=self.p.optimal_ordering)
-        ddata = dendrogram(Z, labels=labels, no_plot=True)
-        return ddata
+        Z = linkage(
+            X,
+            method=self.p.linkage_method,
+            metric=self.p.linkage_metric,
+            optimal_ordering=self.p.optimal_ordering
+        )
+        return dendrogram(Z, labels=labels, no_plot=True)
 
     def _process(self, element, key=None):
         element_kdims = element.kdims
@@ -1270,7 +1301,7 @@ class dendrogram(Operation):
         sort_dims, dendros = [], {}
         for d in self.p.adjoint_dims:
             ddata = self._compute_linkage(dataset, d, self.p.main_dim)
-            order = [ddata["ivl"].index(v) for v in dataset.dimension_values(d)][::-1]
+            order = [ddata["ivl"].index(v) for v in dataset.dimension_values(d)]
             sort_dim = f"sort_{d}"
             dataset = dataset.add_dimension(sort_dim, 0, order)
             sort_dims.append(sort_dim)
diff --git a/holoviews/plotting/bokeh/__init__.py b/holoviews/plotting/bokeh/__init__.py
index b6fbfa100..f5c828a14 100644
--- a/holoviews/plotting/bokeh/__init__.py
+++ b/holoviews/plotting/bokeh/__init__.py
@@ -243,7 +243,7 @@ options.Curve = Options('style', color=Cycle(), line_width=2)
 options.BoxWhisker = Options('style', box_fill_color=Cycle(), whisker_color='black',
                              box_line_color='black', outlier_color='black')
 options.Scatter = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap)
-options.Points = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap)
+options.Points = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap, radius_dimension="min")
 options.Points = Options('plot', show_frame=True)
 options.Histogram = Options('style', line_color='black', color=Cycle(), muted_alpha=0.2)
 options.ErrorBars = Options('style', color='black')
diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py
index 870540dee..b335eb824 100644
--- a/holoviews/plotting/bokeh/chart.py
+++ b/holoviews/plotting/bokeh/chart.py
@@ -1,3 +1,4 @@
+import math
 from collections import defaultdict
 
 import numpy as np
@@ -63,12 +64,21 @@ class PointPlot(LegendPlot, ColorbarPlot):
 
     style_opts = [
         "cmap", "palette", "marker", "size", "angle", "hit_dilation",
+        "radius", "radius_dimension",
         *base_properties, *line_properties, *fill_properties
     ]
 
     _plot_methods = dict(single='scatter', batched='scatter')
     _batched_style_opts = line_properties + fill_properties + ['size', 'marker', 'angle']
 
+    def _init_glyph(self, plot, mapping, properties):
+        if "radius" in properties:
+            self._plot_methods = dict(single='circle', batched='circle')
+            properties.pop("size", None)
+        else:
+            properties.pop("radius_dimension", None)
+        return super()._init_glyph(plot, mapping, properties)
+
     def _get_size_data(self, element, ranges, style):
         data, mapping = {}, {}
         sdim = element.get_dimension(self.size_index)
@@ -430,11 +440,38 @@ class HistogramPlot(ColorbarPlot):
     _nonvectorized_styles = [*base_properties, "line_dash"]
     _plot_methods = dict(single='quad')
 
+    def initialize_plot(self, ranges=None, plot=None, plots=None, source=None):
+        plot = super().initialize_plot(ranges, plot, plots, source)
+        logx = self.logx and self.invert_axes
+        logy = self.logy and not self.invert_axes
+        if logx or logy:
+            if logy:
+                range_ = plot.y_range
+                pos = "end" if self.invert_yaxis else "start"
+            else:
+                range_ = plot.x_range
+                pos = "end" if self.invert_xaxis else "start"
+            source = self.handles["source"]
+            # Insert bottom to the lower value of the axis
+            source.data['bottom'] = [getattr(range_, pos)] * len(source.data['top'])
+            callback = CustomJS(
+                args=dict(source=source, range=range_, pos=pos),
+                code="""
+                source.data['bottom'].fill(range[pos]);
+                source.change.emit();
+            """,
+            )
+            range_.js_on_change(pos, callback)
+
+        return plot
+
     def get_data(self, element, ranges, style):
         if self.invert_axes:
-            mapping = dict(top='right', bottom='left', left=0, right='top')
+            left = 'bottom' if self.logx else 0
+            mapping = dict(top='right', bottom='left', left=left, right='top')
         else:
-            mapping = dict(top='top', bottom=0, left='left', right='right')
+            bottom = 'bottom' if self.logy else 0
+            mapping = dict(top='top', bottom=bottom, left='left', right='right')
         if self.static_source:
             data = dict(top=[], left=[], right=[])
         else:
@@ -445,7 +482,7 @@ class HistogramPlot(ColorbarPlot):
                 edges = edges.compute()
             data = dict(top=values, left=edges[:-1], right=edges[1:])
             self._get_hover_data(data, element)
-        return (data, mapping, style)
+        return data, mapping, style
 
     def get_extents(self, element, ranges, range_type='combined', **kwargs):
         ydim = element.get_dimension(1)
@@ -455,6 +492,12 @@ class HistogramPlot(ColorbarPlot):
         ranges[ydim.label]['soft'] = (s0, s1)
         return super().get_extents(element, ranges, range_type)
 
+    def _update_range(self, axis_range, low, high, factors, invert, shared, log, streaming=False):
+        # We allow zero values with histogram
+        if log and low == 0:
+            low = 0.01 if high > 0.01 else 10**(math.log10(high)-2)
+        return super()._update_range(axis_range, low, high, factors, invert, shared, log, streaming)
+
 
 class SideHistogramPlot(HistogramPlot):
 
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index e76a1d4c7..b23b0a915 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -3237,7 +3237,10 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
                     else:
                         tool_type = type(tool)
                     if isinstance(tool, tools.HoverTool):
-                        tooltips = tuple(tool.tooltips) if tool.tooltips else ()
+                        if isinstance(tool.tooltips, bokeh.models.dom.Div):
+                            tooltips = tool.tooltips
+                        else:
+                            tooltips = tuple(tool.tooltips) if tool.tooltips else ()
                         if tooltips in hover_tools:
                             continue
                         else:
@@ -3267,7 +3270,9 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
             self.handles['hover'] = subplot.handles['hover']
         elif 'hover' in subplot.handles and 'hover_tools' in self.handles:
             hover = subplot.handles['hover']
-            if hover.tooltips and not isinstance(hover.tooltips, str):
+            if hover.tooltips and isinstance(hover.tooltips, bokeh.models.dom.Div):
+                tooltips = hover.tooltips
+            elif hover.tooltips and not isinstance(hover.tooltips, str):
                 tooltips = tuple((name, spec.replace('{%F %T}', '')) for name, spec in hover.tooltips)
             else:
                 tooltips = ()
diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py
index e80b8b3dc..f05c78895 100644
--- a/holoviews/plotting/mpl/element.py
+++ b/holoviews/plotting/mpl/element.py
@@ -411,7 +411,7 @@ class ElementPlot(GenericElementPlot, MPLPlot):
         lims = {}
         valid_lim = lambda c: util.isnumeric(c) and not np.isnan(c)
         if not isinstance(low, util.datetime_types) and log and (low is None or low <= 0):
-            low = 0.01 if high < 0.01 else 10**(np.log10(high)-2)
+            low = 0.01 if high > 0.01 else 10**(np.log10(high)-2)
             self.param.warning(
                 "Logarithmic axis range encountered value less "
                 "than or equal to zero, please supply explicit "
diff --git a/pyproject.toml b/pyproject.toml
index f37e1bd15..75d288d9c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,8 @@ name = "holoviews"
 dynamic = ["version"]
 description = "A high-level plotting API for the PyData ecosystem built on HoloViews."
 readme = "README.md"
-license = { text = "BSD" }
+license = "BSD-3-Clause"
+license-files = ["LICENSE.txt"]
 requires-python = ">=3.10"
 authors = [
     { name = "Jean-Luc Stevens", email = "developers@holoviz.org" },
@@ -15,7 +16,6 @@ authors = [
 ]
 maintainers = [{ name = "HoloViz developers", email = "developers@holoviz.org" }]
 classifiers = [
-    "License :: OSI Approved :: BSD License",
     "Development Status :: 5 - Production/Stable",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",


Test patch:
diff --git a/holoviews/tests/core/data/test_pandasinterface.py b/holoviews/tests/core/data/test_pandasinterface.py
index 1c4022922..aeb4c180d 100644
--- a/holoviews/tests/core/data/test_pandasinterface.py
+++ b/holoviews/tests/core/data/test_pandasinterface.py
@@ -4,6 +4,7 @@ import pytest
 
 from holoviews.core.data import Dataset
 from holoviews.core.data.interface import DataError
+from holoviews.core.data.pandas import PandasInterface
 from holoviews.core.dimension import Dimension
 from holoviews.core.spaces import HoloMap
 from holoviews.element import Distribution, Points, Scatter
@@ -351,6 +352,16 @@ class PandasInterfaceMultiIndex(HeterogeneousColumnTests, InterfaceTests):
         expected = self.df.loc[[2]]
         pd.testing.assert_frame_equal(selected.data, expected)
 
+    def test_select_index_and_column(self):
+        # See https://github.com/holoviz/holoviews/issues/6578
+        frame = pd.DataFrame({"number": [1, 1, 2, 2], "color": ["red", "blue", "red", "blue"]})
+        index = pd.MultiIndex.from_frame(frame, names=("number", "color"))
+        df = pd.DataFrame({"cat": list("abab"), "values": range(4)}, index=index)
+        ds = Dataset(df, kdims=["number"], vdims=["cat", "values"])
+        selected = ds.select(number=[1], cat=["a"])
+        expected = df[(df.index.get_level_values(0) == 1) & (df["cat"] == "a")]
+        pd.testing.assert_frame_equal(selected.data, expected)
+
     def test_sample(self):
         ds = Dataset(self.df, kdims=["number", "color"])
         sample = ds.interface.sample(ds, [1])
@@ -404,3 +415,10 @@ class PandasInterfaceMultiIndex(HeterogeneousColumnTests, InterfaceTests):
 
         plot = Scatter(self.df, kdims="number")
         np.testing.assert_equal(plot.dimension_values('number'), self.df.index.get_level_values('number'))
+
+
+def test_no_subclasse_interface_applies():
+    spd = pytest.importorskip("spatialpandas")
+    square = spd.geometry.Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])
+    sdf = spd.GeoDataFrame({"geometry": spd.GeoSeries([square, square]), "name": ["A", "B"]})
+    assert PandasInterface.applies(sdf) is False
diff --git a/holoviews/tests/core/test_utils.py b/holoviews/tests/core/test_utils.py
index 47bc751d8..cf954f382 100644
--- a/holoviews/tests/core/test_utils.py
+++ b/holoviews/tests/core/test_utils.py
@@ -8,6 +8,7 @@ from itertools import product
 
 import numpy as np
 import pandas as pd
+import pytest
 
 from holoviews import Dimension, Element
 from holoviews.core.util import (
@@ -22,6 +23,7 @@ from holoviews.core.util import (
     find_range,
     get_path,
     is_nan,
+    is_null_or_na_scalar,
     isfinite,
     make_path_unique,
     max_range,
@@ -817,3 +819,30 @@ def test_is_nan():
     assert is_nan([1, 1]) == False
     assert is_nan([np.nan]) == True
     assert is_nan([np.nan, np.nan]) == False
+
+
+def test_is_null_or_na_scalar():
+    assert is_null_or_na_scalar(np.nan)
+    assert is_null_or_na_scalar(pd.NA)
+    assert is_null_or_na_scalar(pd.NaT)
+    assert is_null_or_na_scalar(None)
+    assert is_null_or_na_scalar(np.datetime64("NAT"))
+
+    assert not is_null_or_na_scalar(datetime.datetime.today())
+    assert not is_null_or_na_scalar(pd.Timestamp.now())
+    assert not is_null_or_na_scalar("AAAA")
+    assert not is_null_or_na_scalar(...)
+    assert not is_null_or_na_scalar([1, 2])
+    assert not is_null_or_na_scalar((1, 2))
+    assert not is_null_or_na_scalar({1, 2})
+    assert not is_null_or_na_scalar({"a": 1, "b": 2})
+    assert not is_null_or_na_scalar(slice(None))
+    assert not is_null_or_na_scalar(np.array([1, 2]))
+    assert not is_null_or_na_scalar(pd.DataFrame([1, 2]))
+
+
+def test_is_null_or_na_scalar_polars():
+    pl = pytest.importorskip("polars")
+    assert is_null_or_na_scalar(pl.Null)
+    assert not is_null_or_na_scalar(pl.DataFrame([1, 2]))
+    assert not is_null_or_na_scalar(pl.LazyFrame([1, 2]))
diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py
index cf08006ec..cb609142c 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -31,6 +31,7 @@ from holoviews import (
     Spikes,
     Spread,
     TriMesh,
+    renderer,
 )
 from holoviews.element.comparison import ComparisonTestCase
 from holoviews.operation import apply_when
@@ -1390,6 +1391,12 @@ def test_selector_rasterize(point_plot, sel_fn):
     img_count = rasterize(point_plot, **inputs)
     np.testing.assert_array_equal(img["Count"], img_count["Count"])
 
+@pytest.mark.usefixtures("bokeh_backend")
+def test_selector_hover_in_overlay(point_plot):
+    inputs = dict(dynamic=False,  x_range=(-1, 1), y_range=(-1, 1), width=10, height=10)
+    overlay = rasterize(point_plot, selector=ds.first("val"), **inputs).opts(tools=["hover"]) * Points([])
+    renderer("bokeh").get_plot(overlay)
+
 @pytest.mark.parametrize("sel_fn", (ds.first, ds.last, ds.min, ds.max))
 def test_selector_datashade(point_plot, sel_fn):
     inputs = dict(dynamic=False,  x_range=(-1, 1), y_range=(-1, 1), width=10, height=10)
@@ -1461,6 +1468,16 @@ def test_selector_datashade_bad_column_name(point_data):
         datashade(point_plot, selector=ds.min("val"), **inputs)
 
 
+@pytest.mark.usefixtures("bokeh_backend")
+def test_selector_single_categorical():
+    # Test for https://github.com/holoviz/holoviews/issues/6595
+    plot = Points(([0, 1], [0, 1], ["A", "A"]), ["X", "Y"], "C")
+    plot = rasterize(plot, aggregator=ds.count_cat("C"), selector=ds.first("X"))
+    plot = dynspread(plot)
+    # Should not fail
+    renderer("bokeh").get_plot(plot)
+
+
 class DatashaderSpreadTests(ComparisonTestCase):
 
     def test_spread_rgb_1px(self):
diff --git a/holoviews/tests/plotting/bokeh/test_histogramplot.py b/holoviews/tests/plotting/bokeh/test_histogramplot.py
index 7bff1c46d..855ab6f96 100644
--- a/holoviews/tests/plotting/bokeh/test_histogramplot.py
+++ b/holoviews/tests/plotting/bokeh/test_histogramplot.py
@@ -132,7 +132,9 @@ class TestSideHistogramPlot(LoggingComparisonTestCase, TestBokehPlot):
         self.assertEqual(x_range.end, 3.8)
         self.assertEqual(y_range.start, 0.01)
         self.assertEqual(y_range.end, 3.3483695221017129)
-        self.log_handler.assertContains('WARNING', 'Logarithmic axis range encountered value less than')
+        # We should not have logged 'Logarithmic axis range encountered value less than'
+        last_line = self.log_handler.tail('WARNING', n=1)
+        assert last_line == []
 
     def test_histogram_padding_datetime_square(self):
         histogram = Histogram([(np.datetime64(f'2016-04-0{i}', 'ns'), i) for i in range(1, 4)]).opts(
diff --git a/holoviews/tests/plotting/bokeh/test_overlayplot.py b/holoviews/tests/plotting/bokeh/test_overlayplot.py
index 066ac9c13..838d0dc1a 100644
--- a/holoviews/tests/plotting/bokeh/test_overlayplot.py
+++ b/holoviews/tests/plotting/bokeh/test_overlayplot.py
@@ -1,5 +1,7 @@
 import numpy as np
+import pandas as pd
 import panel as pn
+import pytest
 from bokeh.models import FactorRange, FixedTicker, HoverTool, Range1d, Span
 
 from holoviews.core import DynamicMap, HoloMap, NdOverlay, Overlay
@@ -402,6 +404,25 @@ class TestOverlayPlot(LoggingComparisonTestCase, TestBokehPlot):
             assert y_range.end == ydata.max()
 
 
+@pytest.mark.parametrize('order', [("str", "date"), ("date", "str")])
+def test_ndoverlay_categorical_y_ranges(order):
+    df = pd.DataFrame(
+        {
+            "str": ["apple", "banana", "cherry", "date", "elderberry"],
+            "date": pd.to_datetime(
+                ["2023-01-01", "2023-02-14", "2023-03-21", "2023-04-30", "2023-05-15"]
+            ),
+        }
+    )
+    overlay = NdOverlay(
+        {col: Scatter(df, kdims="index", vdims=col) for col in order}
+    )
+    plot = bokeh_renderer.get_plot(overlay)
+    output = plot.handles["y_range"].factors
+    expected = sorted(map(str, df.values.ravel()))
+    assert output == expected
+
+
 class TestLegends(TestBokehPlot):
 
     def test_overlay_legend(self):
diff --git a/holoviews/tests/plotting/bokeh/test_pointplot.py b/holoviews/tests/plotting/bokeh/test_pointplot.py
index aaced1d36..21a5c8442 100644
--- a/holoviews/tests/plotting/bokeh/test_pointplot.py
+++ b/holoviews/tests/plotting/bokeh/test_pointplot.py
@@ -2,8 +2,15 @@ import datetime as dt
 
 import numpy as np
 import pandas as pd
-from bokeh.models import CategoricalColorMapper, FactorRange, LinearColorMapper, Scatter
-
+from bokeh.models import (
+    CategoricalColorMapper,
+    Circle,
+    FactorRange,
+    LinearColorMapper,
+    Scatter,
+)
+
+import holoviews as hv
 from holoviews.core import NdOverlay
 from holoviews.core.options import Cycle
 from holoviews.element import Points
@@ -540,3 +547,19 @@ class TestPointPlot(TestBokehPlot):
             "size_index; ignoring the size_index.\n"
         )
         self.assertEqual(log_msg, warning)
+
+    def test_point_radius(self):
+        x, y = 4, 5
+        xs = np.arange(x)
+        ys = np.arange(y)
+        zs = np.arange(x * y).reshape(y, x)
+        plot = Points((xs, ys, zs,), kdims=["xs", "ys"], vdims="zs")
+        plot.opts(radius=hv.dim("zs").norm() / 2)
+
+        handles = bokeh_renderer.get_plot(plot).handles
+        glyph = handles["glyph"]
+        assert isinstance(glyph, Circle)
+        assert glyph.radius_dimension == "min"
+
+        norm = zs.T.ravel() / np.max(zs) / 2
+        np.testing.assert_array_equal(handles["cds"].data["radius"], norm)
diff --git a/holoviews/tests/plotting/matplotlib/test_areaplot.py b/holoviews/tests/plotting/matplotlib/test_areaplot.py
index b1bf4a259..6437ed39c 100644
--- a/holoviews/tests/plotting/matplotlib/test_areaplot.py
+++ b/holoviews/tests/plotting/matplotlib/test_areaplot.py
@@ -96,7 +96,7 @@ class TestAreaPlot(LoggingComparisonTestCase, TestMPLPlot):
         x_range, y_range = plot.handles['axis'].get_xlim(), plot.handles['axis'].get_ylim()
         self.assertEqual(x_range[0], 0.8)
         self.assertEqual(x_range[1], 3.2)
-        self.assertEqual(y_range[0], 0.03348369522101712)
+        self.assertEqual(y_range[0], 0.01)
         self.assertEqual(y_range[1], 3.3483695221017129)
         self.log_handler.assertContains('WARNING', 'Logarithmic axis range encountered value less than')
 
diff --git a/holoviews/tests/plotting/matplotlib/test_histogramplot.py b/holoviews/tests/plotting/matplotlib/test_histogramplot.py
index dab971f19..9ec36af50 100644
--- a/holoviews/tests/plotting/matplotlib/test_histogramplot.py
+++ b/holoviews/tests/plotting/matplotlib/test_histogramplot.py
@@ -76,7 +76,7 @@ class TestHistogramPlot(LoggingComparisonTestCase, TestMPLPlot):
         x_range, y_range = plot.handles['axis'].get_xlim(), plot.handles['axis'].get_ylim()
         self.assertEqual(x_range[0], 0.19999999999999996)
         self.assertEqual(x_range[1], 3.8)
-        self.assertEqual(y_range[0], 0.03348369522101712)
+        self.assertEqual(y_range[0], 0.01)
         self.assertEqual(y_range[1], 3.3483695221017129)
         self.log_handler.assertContains('WARNING', 'Logarithmic axis range encountered value less than')
 
diff --git a/holoviews/tests/ui/bokeh/test_histogram.py b/holoviews/tests/ui/bokeh/test_histogram.py
new file mode 100644
index 000000000..130742551
--- /dev/null
+++ b/holoviews/tests/ui/bokeh/test_histogram.py
@@ -0,0 +1,70 @@
+import pytest
+from bokeh.models import CustomJS
+
+from holoviews.element import Histogram
+
+from .. import expect, wait_until
+
+
+def watch_hook(dim, pos):
+    # Setting up monitoring, as I cannot get the data to sync back to Python
+    def create_hook(plot, element):
+        range_ = getattr(plot.handles["plot"], f"{dim}_range")
+        cjs = CustomJS(
+            args=dict(source=plot.handles["source"], range_=range_),
+            code=f"""
+                window.bokeh_cds_data = source.data['bottom']
+                window.bokeh_range = range_.{pos}
+            """,
+        )
+        range_.js_on_change("start", cjs)
+
+    return create_hook
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.parametrize(
+    ["opts_kwargs", "hook", "drag_direction"],
+    [
+        ({"logy": True}, watch_hook("y", "start"), "down"),
+        ({"logx": True, "invert_axes": True}, watch_hook("x", "start"), "right"),
+        ({"logy": True, "invert_yaxis": True}, watch_hook("y", "end"), "up"),
+        ({"logx": True, "invert_axes": True, "invert_xaxis": True}, watch_hook("x", "end"), "left"),
+    ],
+    ids=["logy", "logx", "logy-invert", "logx-invert"],
+)
+def test_histogram_log_scaling(serve_hv, opts_kwargs, hook, drag_direction):
+    hist = Histogram(([1, 3, 6, 9], [1, 10, 0, 100])).opts(hooks=[hook], **opts_kwargs)
+
+    page = serve_hv(hist)
+    hv_plot = page.locator(".bk-events")
+    expect(hv_plot).to_have_count(1)
+    bbox = hv_plot.bounding_box()
+
+    center_x = bbox["x"] + bbox["width"] / 2
+    center_y = bbox["y"] + bbox["height"] / 2
+    page.mouse.move(center_x, center_y)
+
+    match drag_direction:
+        case "down":
+            target_x, target_y = center_x, bbox["y"] - bbox["height"]
+        case "up":
+            target_x, target_y = center_x, bbox["y"] + bbox["height"]
+        case "right":
+            target_x, target_y = bbox["x"] + bbox["width"], center_y
+        case "left":
+            target_x, target_y = bbox["x"] - bbox["width"], center_y
+        case _:
+            raise ValueError(f"Unknown drag direction: {drag_direction}")
+
+    page.mouse.down()
+    page.mouse.move(target_x, target_y)
+    page.mouse.up()
+
+    def f():
+        cds_value = page.evaluate("() => window.bokeh_cds_data")[0]
+        range_value = page.evaluate("() => window.bokeh_range")
+        assert cds_value != 0.01  # default value
+        assert cds_value == range_value
+
+    wait_until(f, page=page)