Repository: holoviz/holoviews

Issue:
Categorical order in hierarchical axis not respected
<!--
Thanks for contacting us! Please read and follow these instructions carefully, then you can delete this introductory text. Note that the issue tracker is NOT the place for usage questions and technical assistance; post those at [Discourse](https://discourse.holoviz.org) instead. Issues without the required information below may be closed immediately.
-->


#### ALL software version info
(this library, plus any other relevant software, e.g. bokeh, python, notebook, OS, browser, etc should be added within the dropdown below.)

<details>
    <summary>Software Version Info</summary>

```plaintext
Using Python 3.12.7 environment at /home/phil/.local/share/hatch/env/virtual/dask millions of cells/cMqPOBoB/dask millions of cells
Package            Version
------------------ -----------
asttokens          2.4.1
bleach             6.2.0
bokeh              3.6.1
certifi            2024.8.30
charset-normalizer 3.4.0
colorcet           3.1.0
comm               0.2.2
contourpy          1.3.1
cycler             0.12.1
debugpy            1.8.8
decorator          5.1.1
executing          2.1.0
fonttools          4.55.0
holoviews          1.20.0
idna               3.10
ipykernel          6.29.5
ipython            8.29.0
ipywidgets         8.1.5
jedi               0.19.2
jinja2             3.1.4
jupyter-client     8.6.3
jupyter-core       5.7.2
jupyterlab-widgets 3.0.13
kiwisolver         1.4.7
linkify-it-py      2.0.3
markdown           3.7
markdown-it-py     3.0.0
markupsafe         3.0.2
matplotlib         3.9.2
matplotlib-inline  0.1.7
mdit-py-plugins    0.4.2
mdurl              0.1.2
mizani             0.13.0
nest-asyncio       1.6.0
numpy              2.1.3
packaging          24.2
pandas             2.2.3
panel              1.5.4
param              2.1.1
parso              0.8.4
patsy              1.0.1
pexpect            4.9.0
pillow             11.0.0
platformdirs       4.3.6
plotnine           0.14.1
prompt-toolkit     3.0.48
psutil             6.1.0
ptyprocess         0.7.0
pure-eval          0.2.3
pygments           2.18.0
pyparsing          3.2.0
python-dateutil    2.9.0.post0
pytz               2024.2
pyviz-comms        3.0.3
pyyaml             6.0.2
pyzmq              26.2.0
requests           2.32.3
scipy              1.14.1
six                1.16.0
stack-data         0.6.3
statsmodels        0.14.4
tornado            6.4.1
tqdm               4.67.0
traitlets          5.14.3
typing-extensions  4.12.2
tzdata             2024.2
uc-micro-py        1.0.3
urllib3            2.2.3
wcwidth            0.2.13
webencodings       0.5.1
widgetsnbextension 4.0.13
xyzservices        2024.9.0
```
</details>

#### Description of expected behavior and the observed behavior
When using multiple kvars, the category order is ignored:

#### Complete, minimal, self-contained example code that reproduces the issue

```python
import pandas as pd
import holoviews as hv
hv.extension('bokeh')

cells_dtype = pd.CategoricalDtype(pd.array(["~1M", "~10M", "~100M"], dtype="string"), ordered=True)

df = pd.DataFrame(dict(
    cells=cells_dtype.categories.astype(cells_dtype),
    time=pd.array([2.99, 18.5, 835.2]),
    function=pd.array(["read", "read", "read"]),
))

hv.Bars(df, ["function", "cells"], ["time"])
```

#### Stack traceback and/or browser JavaScript console output
```pycon
>>> df["cells"]
0      ~1M
1     ~10M
2    ~100M
Name: cells, dtype: category
Categories (3, string): [~1M < ~10M < ~100M]
```

#### Screenshots or screencasts of the bug in action
![Image](https://github.com/user-attachments/assets/6d0cc2df-1f87-4395-9e8c-0d8350dd74c6)

- [x] I may be interested in making a pull request to address this


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6481?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 `2 lines` in your changes missing coverage. Please review.
> Project coverage is 88.74%. Comparing base [(`b9243ba`)](https://app.codecov.io/gh/holoviz/holoviews/commit/b9243ba75bacd0bdd149d489f830fd36ecf29acd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`5a0afa3`)](https://app.codecov.io/gh/holoviz/holoviews/commit/5a0afa34999d0bbeb81cd4a47c25fb19a9343d71?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/6481?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/element/util.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6481?src=pr&el=tree&filepath=holoviews%2Felement%2Futil.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL2VsZW1lbnQvdXRpbC5weQ==) | 87.50% | [2 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6481?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    #6481      +/-   ##
==========================================
- Coverage   88.76%   88.74%   -0.02%     
==========================================
  Files         323      323              
  Lines       68681    68676       -5     
==========================================
- Hits        60963    60945      -18     
- Misses       7718     7731      +13     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6481?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/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..5b0d8d880
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+# The "shellcheck" pre-commit hook requires shell scripts to end lines with LF
+# regardless of git's "core.autocrlf" setting.
+*.sh text eol=lf
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9ff3ee2db..d715bc74d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,7 +17,7 @@ repos:
       - id: check-json
       - id: detect-private-key
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.8.1
+    rev: v0.9.1
     hooks:
       - id: ruff
         files: holoviews/|scripts/
@@ -43,7 +43,7 @@ repos:
     hooks:
       - id: shellcheck
   - repo: https://github.com/hoxbro/prettier-pre-commit
-    rev: v3.4.1
+    rev: v3.4.2
     hooks:
       - id: prettier
         exclude: conda.recipe/meta.yaml
diff --git a/holoviews/core/data/__init__.py b/holoviews/core/data/__init__.py
index 6587f668b..84ed0a86d 100644
--- a/holoviews/core/data/__init__.py
+++ b/holoviews/core/data/__init__.py
@@ -264,6 +264,8 @@ class Dataset(Element, metaclass=PipelineMeta):
     _vdim_reductions = {}
     _kdim_reductions = {}
 
+    interface: Interface
+
     def __new__(cls, data=None, kdims=None, vdims=None, **kwargs):
         """
         Allows casting a DynamicMap to an Element class like hv.Curve, by applying the
diff --git a/holoviews/core/dimension.py b/holoviews/core/dimension.py
index c1c7a1999..a9b2c91b2 100644
--- a/holoviews/core/dimension.py
+++ b/holoviews/core/dimension.py
@@ -3,6 +3,8 @@ Provides Dimension objects for tracking the properties of a value,
 axis or map dimension. Also supplies the Dimensioned abstract
 baseclass for classes that accept Dimension values.
 """
+from __future__ import annotations
+
 import builtins
 import datetime as dt
 import re
@@ -922,7 +924,7 @@ class Dimensioned(LabelledData):
                 if label else dim for dim in dims]
 
 
-    def get_dimension(self, dimension, default=None, strict=False):
+    def get_dimension(self, dimension, default=None, strict=False) -> Dimension | None:
         """Get a Dimension object by name or index.
 
         Args:
diff --git a/holoviews/element/util.py b/holoviews/element/util.py
index 166aeb5f3..130be0c0c 100644
--- a/holoviews/element/util.py
+++ b/holoviews/element/util.py
@@ -1,4 +1,7 @@
+from __future__ import annotations
+
 import itertools
+from typing import TYPE_CHECKING
 
 import numpy as np
 import pandas as pd
@@ -19,6 +22,11 @@ from ..core.util import (
     sort_topologically,
 )
 
+if TYPE_CHECKING:
+    from typing import TypeVar
+
+    Array = TypeVar("Array", np.ndarray, pd.api.extensions.ExtensionArray)
+
 
 def split_path(path):
     """
@@ -126,7 +134,7 @@ class categorical_aggregate2d(Operation):
         The grid interface types to use when constructing the gridded Dataset.""")
 
     @classmethod
-    def _get_coords(cls, obj):
+    def _get_coords(cls, obj: Dataset):
         """
         Get the coordinates of the 2D aggregate, maintaining the correct
         sorting order.
@@ -134,10 +142,11 @@ class categorical_aggregate2d(Operation):
         xdim, ydim = obj.dimensions(label=True)[:2]
         xcoords = obj.dimension_values(xdim, False)
         ycoords = obj.dimension_values(ydim, False)
+
         if xcoords.dtype.kind not in 'SUO':
-            xcoords = np.sort(xcoords)
+            xcoords = sort_arr(xcoords)
         if ycoords.dtype.kind not in 'SUO':
-            return xcoords, np.sort(ycoords)
+            return xcoords, sort_arr(ycoords)
 
         # Determine global orderings of y-values using topological sort
         grouped = obj.groupby(xdim, container_type=dict,
@@ -149,19 +158,18 @@ class categorical_aggregate2d(Operation):
             if len(vals) == 1:
                 orderings[vals[0]] = [vals[0]]
             else:
-                for i in range(len(vals)-1):
-                    p1, p2 = vals[i:i+2]
+                for p1, p2 in zip(vals[:-1], vals[1:]):
                     orderings[p1] = [p2]
             if sort:
                 if vals.dtype.kind in ('i', 'f'):
                     sort = (np.diff(vals)>=0).all()
                 else:
-                    sort = np.array_equal(np.sort(vals), vals)
+                    sort = np.array_equal(sort_arr(vals), vals)
         if sort or one_to_one(orderings, ycoords):
-            ycoords = np.sort(ycoords)
+            ycoords = sort_arr(ycoords)
         elif not is_cyclic(orderings):
             coords = list(itertools.chain(*sort_topologically(orderings)))
-            ycoords = coords if len(coords) == len(ycoords) else np.sort(ycoords)
+            ycoords = coords if len(coords) == len(ycoords) else sort_arr(ycoords)
         return np.asarray(xcoords), np.asarray(ycoords)
 
     def _aggregate_dataset(self, obj):
@@ -332,3 +340,9 @@ def connect_edges(graph):
         end = end_ds.array(end_ds.kdims[:2])
         paths.append(np.array([start[0], end[0]]))
     return paths
+
+
+def sort_arr(arr: Array) -> Array:
+    if isinstance(arr, pd.api.extensions.ExtensionArray):
+        return arr[arr.argsort()]
+    return np.sort(arr)
diff --git a/holoviews/ipython/__init__.py b/holoviews/ipython/__init__.py
index a9b56ed09..c2ff66a2f 100644
--- a/holoviews/ipython/__init__.py
+++ b/holoviews/ipython/__init__.py
@@ -100,9 +100,7 @@ class notebook_extension(extension):
         export figures to other formats such as PDF with nbconvert.""")
 
     allow_jedi_completion = param.Boolean(default=True, doc="""
-       Whether to allow jedi tab-completion to be enabled in IPython.
-       Disabled by default because many HoloViews features rely on
-       tab-completion machinery not supported when using jedi.""")
+       Whether to allow jedi tab-completion to be enabled in IPython.""")
 
     case_sensitive_completion = param.Boolean(default=False, doc="""
        Whether to monkey patch IPython to use the correct tab-completion
diff --git a/holoviews/plotting/bokeh/annotation.py b/holoviews/plotting/bokeh/annotation.py
index bf32bb70e..bb5093b37 100644
--- a/holoviews/plotting/bokeh/annotation.py
+++ b/holoviews/plotting/bokeh/annotation.py
@@ -256,11 +256,9 @@ class LineAnnotationPlot(ElementPlot, AnnotationPlot):
     def get_extents(self, element, ranges=None, range_type='combined', **kwargs):
         loc = element.data
         if isinstance(element, VLine):
-            dim = 'x'
+            dim = 'y' if self.invert_axes else 'x'
         elif isinstance(element, HLine):
-            dim = 'y'
-        if self.invert_axes:
-            dim = 'x' if dim == 'y' else 'x'
+            dim = 'x' if self.invert_axes else 'y'
         ranges[dim]['soft'] = loc, loc
         return super().get_extents(element, ranges, range_type)
 
diff --git a/pixi.toml b/pixi.toml
index e750e56f4..63ca5bfda 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -139,7 +139,7 @@ _install-ui = 'playwright install chromium'
 
 [feature.test-ui.tasks.test-ui]
 cmd = 'pytest holoviews/tests/ui --ui --browser chromium'
-depends_on = ["_install-ui"]
+depends-on = ["_install-ui"]
 
 [feature.test-gpu]
 channels = ["rapidsai"]
@@ -183,7 +183,7 @@ _docs-refmanual = 'python ./doc/generate_modules.py holoviews -d ./doc/reference
 _docs-generate = 'nbsite build --what=html --output=builtdocs --org holoviz --project-name holoviews'
 
 [feature.doc.tasks.docs-build]
-depends_on = ['_docs-generate-rst', '_docs-refmanual', '_docs-generate']
+depends-on = ['_docs-generate-rst', '_docs-refmanual', '_docs-generate']
 
 # =============================================
 # ================== BUILD ====================


Test patch:
diff --git a/holoviews/tests/plotting/bokeh/test_barplot.py b/holoviews/tests/plotting/bokeh/test_barplot.py
index de5207988..0b3c68aec 100644
--- a/holoviews/tests/plotting/bokeh/test_barplot.py
+++ b/holoviews/tests/plotting/bokeh/test_barplot.py
@@ -351,6 +351,27 @@ class TestBarPlot(TestBokehPlot):
         plot = bokeh_renderer.get_plot(bars)
         assert plot.handles["glyph"].width == 1
 
+    def test_bars_categorical_order(self):
+        cells_dtype = pd.CategoricalDtype(
+            pd.array(["~1M", "~10M", "~100M"], dtype="string"),
+            ordered=True,
+        )
+        df = pd.DataFrame(dict(
+            cells=cells_dtype.categories.astype(cells_dtype),
+            time=pd.array([2.99, 18.5, 835.2]),
+            function=pd.array(["read", "read", "read"]),
+        ))
+
+        bars = Bars(df, ["function", "cells"], ["time"])
+        plot = bokeh_renderer.get_plot(bars)
+        x_factors = plot.handles["x_range"].factors
+
+        np.testing.assert_equal(x_factors, [
+            ("read", "~1M"),
+            ("read", "~10M"),
+            ("read", "~100M"),
+        ])
+
     def test_bars_group(self):
         samples = 100