Skip to content

Commit 44bbb76

Browse files
authored
Merge branch 'main' into fix_clustermap
2 parents 015e58e + 30c842e commit 44bbb76

3 files changed

Lines changed: 136 additions & 26 deletions

File tree

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@ repos:
55
- id: trailing-whitespace
66
- id: end-of-file-fixer
77
- repo: https://github.com/astral-sh/ruff-pre-commit
8-
rev: v0.15.20
8+
rev: v0.15.21
99
hooks:
1010
- id: ruff-check
1111
args: [--fix, --preview]
1212
- id: ruff-format
1313
- repo: https://github.com/tox-dev/pyproject-fmt
14-
rev: v2.25.1
14+
rev: v2.25.2
1515
hooks:
1616
- id: pyproject-fmt
1717
- repo: https://github.com/biomejs/pre-commit
18-
rev: v2.5.2
18+
rev: v2.5.3
1919
hooks:
2020
- id: biome-format
2121
- repo: https://github.com/kynan/nbstripout

src/hv_anndata/plotting/manifoldmap.py

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -518,10 +518,16 @@ def _update_on_color_by(self) -> None:
518518
if old_is_categorical != self._categorical or not self.colormap:
519519
cmaps = CAT_CMAPS if self._categorical else CONT_CMAPS
520520
self.param.colormap.objects = cmaps
521-
if self.colormap is None or self.colormap not in cmaps:
522-
self.colormap = next(iter(cmaps.values()))
523-
else:
524-
self.colormap = cmaps[self.colormap]
521+
# colormap holds a palette (list) once selected, or a name (str)
522+
# initially; membership must test values, not (unhashable) keys.
523+
current = (
524+
cmaps.get(self.colormap)
525+
if isinstance(self.colormap, str)
526+
else self.colormap
527+
)
528+
if current not in cmaps.values():
529+
current = next(iter(cmaps.values()))
530+
self.colormap = current
525531
self._replot = True
526532

527533
@hold()
@@ -540,8 +546,11 @@ def _update_axes(self) -> None:
540546
def _get_dim(self) -> AdDim:
541547
if self.color_by_dim == "obs":
542548
return A.obs[self.color_by]
549+
# Color each observation by the gene's expression (a column of X, i.e. an
550+
# obs-axis vector) so it matches the obsm-based x/y coordinates.
551+
# Referencing the var axis here raises a DataError (obs vs var mismatch).
543552
if not self.var_reference:
544-
return A.var.index
553+
return A.X[:, self.color_by]
545554
var = self.adata.var.query(f'feature_name == "{self.color_by}"').index
546555
if len(var) > 1:
547556
msg = (
@@ -621,6 +630,9 @@ def create_plot(
621630
"""
622631
dr_label = self.get_reduction_label(dr_key)
623632

633+
if not color_by:
634+
return pmui.pane.Typography("Please select a variable to color by.")
635+
624636
if x_value == y_value:
625637
return pmui.pane.Typography(
626638
"Please select different dimensions for X and Y axes."
@@ -696,6 +708,11 @@ def _plot_view(self) -> hv.Element:
696708
show_labels=self.show_labels,
697709
cmap=self.colormap,
698710
)
711+
# create_plot returns a Typography pane (not a HoloViews element) for
712+
# invalid axis selections, e.g. the transient x == y state while
713+
# switching reductions; only apply opts to real hv elements.
714+
if not isinstance(plot, hv.core.Dimensioned):
715+
return plot
699716
return plot.opts(**self.plot_opts)
700717

701718
def __panel__(self) -> pn.viewable.Viewable:
@@ -737,21 +754,24 @@ def __panel__(self) -> pn.viewable.Viewable:
737754
stylesheets=[stylesheet],
738755
sizing_mode="stretch_width",
739756
)
740-
# Create widget box
741-
widgets = pmui.Column(
742-
pmui.widgets.Select.from_param(
743-
self.param.reduction,
744-
description="",
745-
sizing_mode="stretch_width",
746-
),
747-
pmui.widgets.Select.from_param(
748-
self.param.x_axis,
749-
sizing_mode="stretch_width",
750-
),
751-
pmui.widgets.Select.from_param(
752-
self.param.y_axis,
753-
sizing_mode="stretch_width",
757+
# Create widget box in a Bokeh pn.Column so the Bokeh ColorMap isn't a
758+
# child of a React subtree (React tears out its DOM on mount). The
759+
# selectors use AutocompleteInput rather than Select: pmui's Select
760+
# renders its dropdown in an MUI portal that mis-anchors to the top-left
761+
# inside a Bokeh layout, whereas AutocompleteInput anchors correctly.
762+
select_kw = dict(
763+
min_characters=0,
764+
search_strategy="includes",
765+
case_sensitive=False,
766+
description="",
767+
sizing_mode="stretch_width",
768+
)
769+
widgets = pn.Column(
770+
pmui.widgets.AutocompleteInput.from_param(
771+
self.param.reduction, **select_kw
754772
),
773+
pmui.widgets.AutocompleteInput.from_param(self.param.x_axis, **select_kw),
774+
pmui.widgets.AutocompleteInput.from_param(self.param.y_axis, **select_kw),
755775
color_by_dim,
756776
color,
757777
colormap,
@@ -767,9 +787,8 @@ def __panel__(self) -> pn.viewable.Viewable:
767787
visible=self.param._categorical, # noqa: SLF001
768788
),
769789
pmui.Details(
770-
pmui.widgets.Select.from_param(
771-
self.param.legend_position,
772-
sizing_mode="stretch_width",
790+
pmui.widgets.AutocompleteInput.from_param(
791+
self.param.legend_position, **select_kw
773792
),
774793
pn.widgets.FloatSlider.from_param(
775794
self.param.legend_alpha,
@@ -793,7 +812,7 @@ def __panel__(self) -> pn.viewable.Viewable:
793812
visible=self.param._categorical, # noqa: SLF001
794813
),
795814
visible=self.param.show_widgets,
796-
sx={"border": 1, "borderColor": "#e3e3e3", "borderRadius": 1},
815+
styles={"border": "1px solid #e3e3e3", "border-radius": "4px"},
797816
sizing_mode="stretch_width",
798817
max_width=280,
799818
min_height=590,

tests/plotting/test_manifold.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
import numpy as np
1111
import pandas as pd
1212
import panel as pn
13+
import panel_material_ui as pmui
1314
import pytest
1415
from holoviews.operation.datashader import dynspread, rasterize
1516

1617
from hv_anndata import A, ManifoldMap, create_manifoldmap_plot
1718
from hv_anndata.interface import register, unregister
19+
from hv_anndata.plotting.manifoldmap import CAT_CMAPS, CONT_CMAPS
1820

1921
if TYPE_CHECKING:
2022
from collections.abc import Iterator
@@ -211,3 +213,92 @@ def test_manifoldmap_streams(sadata: ad.AnnData) -> None:
211213
assert bounds_xy.source is None
212214
mm.__panel__()
213215
assert bounds_xy.source is not None
216+
217+
218+
@pytest.mark.usefixtures("bokeh_renderer")
219+
def test_manifoldmap_get_dim_cols_uses_x_column(sadata: ad.AnnData) -> None:
220+
"""color_by_dim="cols" without var_reference must read expression from X.
221+
222+
A.var.index has no per-observation values and mismatches the obsm-based
223+
x/y coordinates on the obs axis; see #161.
224+
"""
225+
mm = ManifoldMap(adata=sadata, color_by_dim="cols", color_by="gene_1")
226+
227+
assert mm._get_dim() == A.X[:, "gene_1"]
228+
229+
230+
@pytest.mark.usefixtures("bokeh_renderer")
231+
def test_manifoldmap_color_by_cols_plot_view_no_var_reference(
232+
sadata: ad.AnnData,
233+
) -> None:
234+
"""Coloring by a variable (no var_reference) renders instead of raising.
235+
236+
Previously raised a HoloViews DataError mixing the obs and var axes; see
237+
#161.
238+
"""
239+
mm = ManifoldMap(adata=sadata, color_by_dim="cols", color_by="gene_1")
240+
241+
plot = mm._plot_view()
242+
243+
assert isinstance(plot, hv.core.Dimensioned)
244+
assert mm._categorical is False
245+
246+
247+
@pytest.mark.usefixtures("bokeh_renderer")
248+
def test_manifoldmap_colormap_survives_categorical_round_trip(
249+
sadata: ad.AnnData,
250+
) -> None:
251+
"""colormap must round-trip through a categorical/continuous/categorical cycle.
252+
253+
Once selected, colormap holds an unhashable palette list rather than its
254+
name, so membership must be tested against dict values, not keys, or this
255+
raises `TypeError: unhashable type: 'list'`; see #161.
256+
"""
257+
mm = ManifoldMap(adata=sadata, color_by="cell_type")
258+
assert mm.colormap in CAT_CMAPS.values()
259+
260+
mm.color_by = "expression_level"
261+
assert mm._categorical is False
262+
assert mm.colormap in CONT_CMAPS.values()
263+
264+
mm.color_by = "cell_type"
265+
assert mm._categorical is True
266+
assert mm.colormap in CAT_CMAPS.values()
267+
268+
269+
@pytest.mark.usefixtures("bokeh_renderer")
270+
@pytest.mark.parametrize("color_by", [None, ""], ids=["none", "empty_string"])
271+
def test_manifoldmap_create_plot_no_color_by(
272+
sadata: ad.AnnData, color_by: str | None
273+
) -> None:
274+
"""create_plot shows a prompt instead of erroring when color_by is unset."""
275+
mm = ManifoldMap(adata=sadata)
276+
277+
result = mm.create_plot(
278+
dr_key="X_umap",
279+
x_value="UMAP1",
280+
y_value="UMAP2",
281+
color_by=color_by,
282+
datashade_value=False,
283+
show_labels=False,
284+
cmap=None,
285+
)
286+
287+
assert isinstance(result, pmui.pane.Typography)
288+
assert "select a variable to color by" in result.object
289+
290+
291+
@pytest.mark.usefixtures("bokeh_renderer")
292+
def test_manifoldmap_plot_view_skips_opts_for_error_pane(sadata: ad.AnnData) -> None:
293+
"""_plot_view must not call .opts() on a Typography error pane from create_plot.
294+
295+
Typography has no `.opts()`, so calling it unconditionally would raise;
296+
see #161.
297+
"""
298+
mm = ManifoldMap(adata=sadata)
299+
error_pane = pmui.pane.Typography("boom")
300+
mm.create_plot = lambda **_: error_pane
301+
302+
plot = mm._plot_view()
303+
304+
assert plot is error_pane

0 commit comments

Comments
 (0)