From b9f7bb98475ab364b10d1b7547130bf0bf99b4d3 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Mon, 24 Mar 2025 11:29:10 -0700 Subject: [PATCH 01/55] dotmap typo --- src/hv_anndata/plotting.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/hv_anndata/plotting.py b/src/hv_anndata/plotting.py index abc6fc0..5e8d50b 100644 --- a/src/hv_anndata/plotting.py +++ b/src/hv_anndata/plotting.py @@ -1,5 +1,3 @@ -"""DotmapPlot using AnnData as input and return a holoviews plot.""" - from __future__ import annotations from itertools import chain @@ -14,7 +12,7 @@ from typing import NotRequired, Unpack -class _DotmatPlotParams(TypedDict): +class _DotmapPlotParams(TypedDict): kdims: NotRequired[list[str | hv.Dimension]] vdims: NotRequired[list[str | hv.Dimension]] adata: ad.AnnData @@ -124,7 +122,7 @@ def _get_opts(self) -> dict[str, Any]: return opts | backend_opts - def __call__(self, **params: Unpack[_DotmatPlotParams]) -> hv.Points: + def __call__(self, **params: Unpack[_DotmapPlotParams]) -> hv.Points: """Create a DotmapPlot from anndata.""" if required := {"adata", "marker_genes", "groupby"} - params.keys(): msg = f"Needs to have the following argument(s): {required}" From b5439f8b8249ea17296e8271a44becd248377eae Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Mon, 24 Mar 2025 16:21:57 -0700 Subject: [PATCH 02/55] add featuremap app --- src/hv_anndata/__init__.py | 3 +- src/hv_anndata/featuremap.py | 274 +++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/hv_anndata/featuremap.py diff --git a/src/hv_anndata/__init__.py b/src/hv_anndata/__init__.py index 464abdc..74c4aa3 100644 --- a/src/hv_anndata/__init__.py +++ b/src/hv_anndata/__init__.py @@ -4,5 +4,6 @@ from .interface import AnnDataInterface, register from .plotting import Dotmap +from .featuremap import FeatureMapApp, create_featuremap_plot -__all__ = ["AnnDataInterface", "Dotmap", "register"] +__all__ = ["AnnDataInterface", "Dotmap", "register", "FeatureMapApp", "create_featuremap_plot"] diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/featuremap.py new file mode 100644 index 0000000..05316eb --- /dev/null +++ b/src/hv_anndata/featuremap.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import numpy as np +import holoviews as hv +import panel as pn +import datashader as ds +import holoviews.operation.datashader as hd +import colorcet as cc +import param +import anndata as ad + +from panel.reactive import hold + +def create_featuremap_plot( + x_data: np.ndarray, + color_data: np.ndarray, + x_dim: int, + y_dim: int, + color_var: str, + xaxis_label: str, + yaxis_label: str, + width: int = 300, + height: int = 300, + datashading: bool = True, + labels: bool = False, + cont_cmap: str = 'viridis', + cat_cmap: list = cc.b_glasbey_category10, + title: str = "", +) -> hv.Element: + """ + Create a comprehensive feature map plot with options for datashading and labels + + Parameters: + - x_data: numpy.ndarray, shape n_obs by n_dimensions + - color_data: numpy.ndarray, shape n_obs color values (categorical or continuous) + - x_dim, y_dim: int, indices to use as x or y data + - color_var: str, name to give the coloring dimension + - xaxis_label, yaxis_label: str, labels for the axes + - width, height: int, dimensions of the plot + - datashading: bool, whether to apply datashader + - labels: bool, whether to overlay labels at median positions + - cont_cmap: str or list, colormap for continuous data + - cat_cmap: str or list, colormap for categorical data + """ + + is_categorical = ( + color_data.dtype.name in ['category', 'categorical', 'bool'] or + np.issubdtype(color_data.dtype, np.object_) or + np.issubdtype(color_data.dtype, np.str_) + ) + + # Set colormap and plot options based on data type + if is_categorical: + n_unq_cat = len(np.unique(color_data)) + # hack so that cat cmap doesn't stretch and skip colors + cmap = cat_cmap[:n_unq_cat] + colorbar = False + if labels: + show_legend = False + else: + show_legend = True + else: + cmap = cont_cmap + show_legend = False + colorbar = True + + plot = hv.Points( + (x_data[:, x_dim], x_data[:, y_dim], color_data), + [xaxis_label, yaxis_label], color_var + ) + + # Options for standard (non-datashaded) plot + plot_opts = dict( + color=color_var, + cmap=cmap, + size=1, + alpha=0.5, + colorbar=colorbar, + padding=0, + tools=['hover'], + show_legend=show_legend, + legend_position='right', + ) + + # Options for labels + label_opts = dict( + text_font_size='8pt', + text_color='black' + ) + + # Apply datashading if requested + if datashading: + if is_categorical: + # For categorical data, count by category + aggregator = ds.count_cat(color_var) + plot = hd.rasterize(plot, aggregator=aggregator) + plot = hd.dynspread(plot, threshold=0.5) + plot = plot.opts(cmap=cmap, tools=['hover']) + + if labels: + # Add labels at median positions + unique_categories = np.unique(color_data) + labels_data = [] + for cat in unique_categories: + mask = color_data == cat + median_x = np.median(x_data[mask, x_dim]) + median_y = np.median(x_data[mask, y_dim]) + labels_data.append((median_x, median_y, str(cat))) + labels_element = hv.Labels(labels_data, [xaxis_label, yaxis_label], 'Label').opts(**label_opts) + plot = plot * labels_element + else: + # Create a custom legend for datashaded categorical plot + unique_categories = np.unique(color_data) + color_key = dict(zip(unique_categories, cmap[:len(unique_categories)])) + legend_items = [ + hv.Points([0,0], label=str(cat)).opts( + color=color_key[cat], + size=0 + ) for cat in unique_categories + ] + legend = hv.NdOverlay({str(cat): item for cat, item in zip(unique_categories, legend_items)}).opts( + show_legend=True, + legend_position='right', + legend_limit=100, + legend_cols=len(unique_categories) // 10 + 1, + ) + plot = plot * legend + else: + # For continuous data, take the mean + aggregator = ds.mean(color_var) + plot = hd.rasterize(plot, aggregator=aggregator) + plot = hd.dynspread(plot, threshold=0.5) + plot = plot.opts(cmap=cmap, colorbar=colorbar) + else: + # Standard plot without datashading + plot = plot.opts(**plot_opts) + if is_categorical and labels: + # Add labels for non-datashaded categorical plot + unique_categories = np.unique(color_data) + labels_data = [] + for cat in unique_categories: + mask = color_data == cat + median_x = np.median(x_data[mask, x_dim]) + median_y = np.median(x_data[mask, y_dim]) + labels_data.append((median_x, median_y, str(cat))) + labels_element = hv.Labels(labels_data, [xaxis_label, yaxis_label], 'Label').opts(**label_opts) + plot = plot * labels_element + + return plot.opts( + title=title, + tools=['hover'], + show_legend=show_legend, + frame_width=width, + frame_height=height + ) + + +class FeatureMapApp(pn.viewable.Viewer): + """Create an interactive feature map application for exploring AnnData objects. + + This application provides widgets to select dimensionality reduction methods, + dimensions for x and y axes, coloring variables, and display options. + """ + + adata = param.ClassSelector(class_=ad.AnnData, doc="AnnData object to visualize") + reduction = param.String(default=None, doc="Initial dimension reduction method", allow_None=True) + color_by = param.String(default=None, doc="Initial coloring variable", allow_None=True) + datashade = param.Boolean(default=True, doc="Whether to enable datashading") + width = param.Integer(default=300, doc="Width of the plot") + height = param.Integer(default=300, doc="Height of the plot") + labels = param.Boolean(default=False, doc="Whether to show labels") + show_widgets = param.Boolean(default=True, doc="Whether to show control widgets") + + def __init__(self, **params): + super().__init__(**params) + self.dr_options = list(self.adata.obsm.keys()) + if not self.reduction: + self.reduction = self.dr_options[0] + # self.default_dr = reduction or dr_options[0] + + self.color_options = list(self.adata.obs.columns) + if not self.color_by: + self.color_by = 'cell_type' if 'cell_type' in self.color_options else self.color_options[0] + # self.default_color = color_by or color_options[0] + + def get_reduction_label(self, dr_key): + return dr_key.split('_')[1].upper() if '_' in dr_key else dr_key.upper() + + def get_dim_labels(self, dr_key): + dr_label = self.get_reduction_label(dr_key) + num_dims = self.adata.obsm[dr_key].shape[1] + return [f"{dr_label}{i+1}" for i in range(num_dims)] + + def create_plot(self, dr_key, x_value, y_value, color_value, datashade_value, label_value): + + x_data = self.adata.obsm[dr_key] + dr_label = self.get_reduction_label(dr_key) + + if x_value == y_value: + return pn.pane.Markdown("Please select different dimensions for X and Y axes.") + + # Extract indices from dimension labels + try: + x_dim = int(x_value.replace(dr_label, "")) - 1 + y_dim = int(y_value.replace(dr_label, "")) - 1 + except (ValueError, AttributeError): + return pn.pane.Markdown(f"Error parsing dimensions. Make sure to select valid {dr_label} dimensions.") + + # Get color data from .obs or X cols + try: + color_data = self.adata.obs[color_value].values + except KeyError: + try: + color_data = self.adata.X.getcol(self.adata.var_names.get_loc(color_value)).toarray().flatten() + except (KeyError, ValueError): + color_data = np.zeros(self.adata.n_obs) + print(f"Warning: Could not find {color_value} in obs or var") + + return create_featuremap_plot( + x_data, + color_data, + x_dim, + y_dim, + color_value, + x_value, + y_value, + width=self.width, + height=self.height, + datashading=datashade_value, + labels=label_value, + title=f"{dr_label}.{color_value}", + ) + + def __panel__(self): + + # Widgets + dr_select = pn.widgets.Select.from_param(self.param.reduction, options=self.dr_options) + initial_dims = self.get_dim_labels(dr_select.value) + x_axis = pn.widgets.Select(name='X-axis', options=initial_dims, value=initial_dims[0]) + y_axis = pn.widgets.Select(name='Y-axis', options=initial_dims, value=initial_dims[1]) + color = pn.widgets.Select.from_param(self.param.color_by, options=self.color_options) + datashade_switch = pn.widgets.Checkbox.from_param(self.param.datashade, name="Datashader Rasterize For Large Datasets") + label_switch = pn.widgets.Checkbox.from_param(self.param.labels, name="Overlay Labels For Categorical Coloring") + + #reset dim options when reduction selection changes + @hold() + def reset_dimension_options(event): + new_dims = self.get_dim_labels(event.new) + x_axis.param.update(options=new_dims, value=new_dims[0]) + y_axis.param.update(options=new_dims, value=new_dims[1]) + + dr_select.param.watch(reset_dimension_options, 'value') + + plot_pane = pn.bind( + self.create_plot, + dr_key=dr_select, + x_value=x_axis, + y_value=y_axis, + color_value=color, + datashade_value=datashade_switch, + label_value=label_switch + ) + + widgets = pn.WidgetBox( + dr_select, + x_axis, + y_axis, + color, + datashade_switch, + label_switch, + visible=self.show_widgets, + ) + + return pn.Row(widgets, plot_pane) \ No newline at end of file From a91cb75c6017d7b9818c10566df04d7abedd03d0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:24:57 +0000 Subject: [PATCH 03/55] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/hv_anndata/__init__.py | 10 +- src/hv_anndata/featuremap.py | 207 +++++++++++++++++++++-------------- 2 files changed, 130 insertions(+), 87 deletions(-) diff --git a/src/hv_anndata/__init__.py b/src/hv_anndata/__init__.py index 74c4aa3..2695393 100644 --- a/src/hv_anndata/__init__.py +++ b/src/hv_anndata/__init__.py @@ -2,8 +2,14 @@ from __future__ import annotations +from .featuremap import FeatureMapApp, create_featuremap_plot from .interface import AnnDataInterface, register from .plotting import Dotmap -from .featuremap import FeatureMapApp, create_featuremap_plot -__all__ = ["AnnDataInterface", "Dotmap", "register", "FeatureMapApp", "create_featuremap_plot"] +__all__ = [ + "AnnDataInterface", + "Dotmap", + "FeatureMapApp", + "create_featuremap_plot", + "register", +] diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/featuremap.py index 05316eb..11df9ca 100644 --- a/src/hv_anndata/featuremap.py +++ b/src/hv_anndata/featuremap.py @@ -1,36 +1,36 @@ from __future__ import annotations -import numpy as np -import holoviews as hv -import panel as pn +import anndata as ad +import colorcet as cc import datashader as ds +import holoviews as hv import holoviews.operation.datashader as hd -import colorcet as cc +import numpy as np +import panel as pn import param -import anndata as ad - from panel.reactive import hold + def create_featuremap_plot( - x_data: np.ndarray, - color_data: np.ndarray, - x_dim: int, - y_dim: int, + x_data: np.ndarray, + color_data: np.ndarray, + x_dim: int, + y_dim: int, color_var: str, - xaxis_label: str, + xaxis_label: str, yaxis_label: str, - width: int = 300, - height: int = 300, - datashading: bool = True, + width: int = 300, + height: int = 300, + datashading: bool = True, labels: bool = False, - cont_cmap: str = 'viridis', + cont_cmap: str = "viridis", cat_cmap: list = cc.b_glasbey_category10, title: str = "", ) -> hv.Element: - """ - Create a comprehensive feature map plot with options for datashading and labels - - Parameters: + """Create a comprehensive feature map plot with options for datashading and labels + + Parameters + ---------- - x_data: numpy.ndarray, shape n_obs by n_dimensions - color_data: numpy.ndarray, shape n_obs color values (categorical or continuous) - x_dim, y_dim: int, indices to use as x or y data @@ -41,14 +41,14 @@ def create_featuremap_plot( - labels: bool, whether to overlay labels at median positions - cont_cmap: str or list, colormap for continuous data - cat_cmap: str or list, colormap for categorical data - """ + """ is_categorical = ( - color_data.dtype.name in ['category', 'categorical', 'bool'] or - np.issubdtype(color_data.dtype, np.object_) or - np.issubdtype(color_data.dtype, np.str_) + color_data.dtype.name in ["category", "categorical", "bool"] + or np.issubdtype(color_data.dtype, np.object_) + or np.issubdtype(color_data.dtype, np.str_) ) - + # Set colormap and plot options based on data type if is_categorical: n_unq_cat = len(np.unique(color_data)) @@ -63,12 +63,13 @@ def create_featuremap_plot( cmap = cont_cmap show_legend = False colorbar = True - + plot = hv.Points( (x_data[:, x_dim], x_data[:, y_dim], color_data), - [xaxis_label, yaxis_label], color_var + [xaxis_label, yaxis_label], + color_var, ) - + # Options for standard (non-datashaded) plot plot_opts = dict( color=color_var, @@ -77,17 +78,14 @@ def create_featuremap_plot( alpha=0.5, colorbar=colorbar, padding=0, - tools=['hover'], + tools=["hover"], show_legend=show_legend, - legend_position='right', + legend_position="right", ) - + # Options for labels - label_opts = dict( - text_font_size='8pt', - text_color='black' - ) - + label_opts = dict(text_font_size="8pt", text_color="black") + # Apply datashading if requested if datashading: if is_categorical: @@ -95,8 +93,8 @@ def create_featuremap_plot( aggregator = ds.count_cat(color_var) plot = hd.rasterize(plot, aggregator=aggregator) plot = hd.dynspread(plot, threshold=0.5) - plot = plot.opts(cmap=cmap, tools=['hover']) - + plot = plot.opts(cmap=cmap, tools=["hover"]) + if labels: # Add labels at median positions unique_categories = np.unique(color_data) @@ -106,21 +104,30 @@ def create_featuremap_plot( median_x = np.median(x_data[mask, x_dim]) median_y = np.median(x_data[mask, y_dim]) labels_data.append((median_x, median_y, str(cat))) - labels_element = hv.Labels(labels_data, [xaxis_label, yaxis_label], 'Label').opts(**label_opts) + labels_element = hv.Labels( + labels_data, [xaxis_label, yaxis_label], "Label" + ).opts(**label_opts) plot = plot * labels_element else: # Create a custom legend for datashaded categorical plot unique_categories = np.unique(color_data) - color_key = dict(zip(unique_categories, cmap[:len(unique_categories)])) + color_key = dict( + zip(unique_categories, cmap[: len(unique_categories)], strict=False) + ) legend_items = [ - hv.Points([0,0], label=str(cat)).opts( - color=color_key[cat], - size=0 - ) for cat in unique_categories + hv.Points([0, 0], label=str(cat)).opts(color=color_key[cat], size=0) + for cat in unique_categories ] - legend = hv.NdOverlay({str(cat): item for cat, item in zip(unique_categories, legend_items)}).opts( + legend = hv.NdOverlay( + { + str(cat): item + for cat, item in zip( + unique_categories, legend_items, strict=False + ) + } + ).opts( show_legend=True, - legend_position='right', + legend_position="right", legend_limit=100, legend_cols=len(unique_categories) // 10 + 1, ) @@ -143,79 +150,98 @@ def create_featuremap_plot( median_x = np.median(x_data[mask, x_dim]) median_y = np.median(x_data[mask, y_dim]) labels_data.append((median_x, median_y, str(cat))) - labels_element = hv.Labels(labels_data, [xaxis_label, yaxis_label], 'Label').opts(**label_opts) + labels_element = hv.Labels( + labels_data, [xaxis_label, yaxis_label], "Label" + ).opts(**label_opts) plot = plot * labels_element - + return plot.opts( title=title, - tools=['hover'], + tools=["hover"], show_legend=show_legend, frame_width=width, - frame_height=height + frame_height=height, ) class FeatureMapApp(pn.viewable.Viewer): """Create an interactive feature map application for exploring AnnData objects. - + This application provides widgets to select dimensionality reduction methods, dimensions for x and y axes, coloring variables, and display options. """ - + adata = param.ClassSelector(class_=ad.AnnData, doc="AnnData object to visualize") - reduction = param.String(default=None, doc="Initial dimension reduction method", allow_None=True) - color_by = param.String(default=None, doc="Initial coloring variable", allow_None=True) + reduction = param.String( + default=None, doc="Initial dimension reduction method", allow_None=True + ) + color_by = param.String( + default=None, doc="Initial coloring variable", allow_None=True + ) datashade = param.Boolean(default=True, doc="Whether to enable datashading") width = param.Integer(default=300, doc="Width of the plot") height = param.Integer(default=300, doc="Height of the plot") labels = param.Boolean(default=False, doc="Whether to show labels") show_widgets = param.Boolean(default=True, doc="Whether to show control widgets") - + def __init__(self, **params): super().__init__(**params) self.dr_options = list(self.adata.obsm.keys()) if not self.reduction: self.reduction = self.dr_options[0] # self.default_dr = reduction or dr_options[0] - + self.color_options = list(self.adata.obs.columns) if not self.color_by: - self.color_by = 'cell_type' if 'cell_type' in self.color_options else self.color_options[0] + self.color_by = ( + "cell_type" + if "cell_type" in self.color_options + else self.color_options[0] + ) # self.default_color = color_by or color_options[0] - + def get_reduction_label(self, dr_key): - return dr_key.split('_')[1].upper() if '_' in dr_key else dr_key.upper() - + return dr_key.split("_")[1].upper() if "_" in dr_key else dr_key.upper() + def get_dim_labels(self, dr_key): dr_label = self.get_reduction_label(dr_key) num_dims = self.adata.obsm[dr_key].shape[1] - return [f"{dr_label}{i+1}" for i in range(num_dims)] - - def create_plot(self, dr_key, x_value, y_value, color_value, datashade_value, label_value): + return [f"{dr_label}{i + 1}" for i in range(num_dims)] + def create_plot( + self, dr_key, x_value, y_value, color_value, datashade_value, label_value + ): x_data = self.adata.obsm[dr_key] dr_label = self.get_reduction_label(dr_key) - + if x_value == y_value: - return pn.pane.Markdown("Please select different dimensions for X and Y axes.") - + return pn.pane.Markdown( + "Please select different dimensions for X and Y axes." + ) + # Extract indices from dimension labels try: x_dim = int(x_value.replace(dr_label, "")) - 1 y_dim = int(y_value.replace(dr_label, "")) - 1 except (ValueError, AttributeError): - return pn.pane.Markdown(f"Error parsing dimensions. Make sure to select valid {dr_label} dimensions.") - + return pn.pane.Markdown( + f"Error parsing dimensions. Make sure to select valid {dr_label} dimensions." + ) + # Get color data from .obs or X cols try: color_data = self.adata.obs[color_value].values except KeyError: try: - color_data = self.adata.X.getcol(self.adata.var_names.get_loc(color_value)).toarray().flatten() + color_data = ( + self.adata.X.getcol(self.adata.var_names.get_loc(color_value)) + .toarray() + .flatten() + ) except (KeyError, ValueError): color_data = np.zeros(self.adata.n_obs) print(f"Warning: Could not find {color_value} in obs or var") - + return create_featuremap_plot( x_data, color_data, @@ -230,27 +256,38 @@ def create_plot(self, dr_key, x_value, y_value, color_value, datashade_value, la labels=label_value, title=f"{dr_label}.{color_value}", ) - + def __panel__(self): - # Widgets - dr_select = pn.widgets.Select.from_param(self.param.reduction, options=self.dr_options) + dr_select = pn.widgets.Select.from_param( + self.param.reduction, options=self.dr_options + ) initial_dims = self.get_dim_labels(dr_select.value) - x_axis = pn.widgets.Select(name='X-axis', options=initial_dims, value=initial_dims[0]) - y_axis = pn.widgets.Select(name='Y-axis', options=initial_dims, value=initial_dims[1]) - color = pn.widgets.Select.from_param(self.param.color_by, options=self.color_options) - datashade_switch = pn.widgets.Checkbox.from_param(self.param.datashade, name="Datashader Rasterize For Large Datasets") - label_switch = pn.widgets.Checkbox.from_param(self.param.labels, name="Overlay Labels For Categorical Coloring") + x_axis = pn.widgets.Select( + name="X-axis", options=initial_dims, value=initial_dims[0] + ) + y_axis = pn.widgets.Select( + name="Y-axis", options=initial_dims, value=initial_dims[1] + ) + color = pn.widgets.Select.from_param( + self.param.color_by, options=self.color_options + ) + datashade_switch = pn.widgets.Checkbox.from_param( + self.param.datashade, name="Datashader Rasterize For Large Datasets" + ) + label_switch = pn.widgets.Checkbox.from_param( + self.param.labels, name="Overlay Labels For Categorical Coloring" + ) - #reset dim options when reduction selection changes + # reset dim options when reduction selection changes @hold() def reset_dimension_options(event): new_dims = self.get_dim_labels(event.new) x_axis.param.update(options=new_dims, value=new_dims[0]) y_axis.param.update(options=new_dims, value=new_dims[1]) - - dr_select.param.watch(reset_dimension_options, 'value') - + + dr_select.param.watch(reset_dimension_options, "value") + plot_pane = pn.bind( self.create_plot, dr_key=dr_select, @@ -258,9 +295,9 @@ def reset_dimension_options(event): y_value=y_axis, color_value=color, datashade_value=datashade_switch, - label_value=label_switch + label_value=label_switch, ) - + widgets = pn.WidgetBox( dr_select, x_axis, @@ -270,5 +307,5 @@ def reset_dimension_options(event): label_switch, visible=self.show_widgets, ) - - return pn.Row(widgets, plot_pane) \ No newline at end of file + + return pn.Row(widgets, plot_pane) From 11407477814a2bc9fe55c994414c4eee67890d8c Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Mon, 24 Mar 2025 16:27:39 -0700 Subject: [PATCH 04/55] add panel datashader deps --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 657ce9a..137d516 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dynamic = [ "description", "version" ] -dependencies = [ "anndata", "holoviews", "numpy" ] +dependencies = [ "anndata", "holoviews", "numpy", "panel", "datashader" ] [tool.hatch.metadata.hooks.docstring-description] [tool.hatch.version] From 6ffa573bccf4b35ccc4a68ef7f763fbd01924931 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:27:48 +0000 Subject: [PATCH 05/55] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 137d516..8880335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dynamic = [ "description", "version" ] -dependencies = [ "anndata", "holoviews", "numpy", "panel", "datashader" ] +dependencies = [ "anndata", "datashader", "holoviews", "numpy", "panel" ] [tool.hatch.metadata.hooks.docstring-description] [tool.hatch.version] From 151b80a284b4202ee1f948c49d37d98fbbf497b2 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Mon, 24 Mar 2025 16:30:40 -0700 Subject: [PATCH 06/55] fix info tip wording on widgets --- src/hv_anndata/featuremap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/featuremap.py index 11df9ca..983f9ad 100644 --- a/src/hv_anndata/featuremap.py +++ b/src/hv_anndata/featuremap.py @@ -173,10 +173,10 @@ class FeatureMapApp(pn.viewable.Viewer): adata = param.ClassSelector(class_=ad.AnnData, doc="AnnData object to visualize") reduction = param.String( - default=None, doc="Initial dimension reduction method", allow_None=True + default=None, doc="Dimension reduction method", allow_None=True ) color_by = param.String( - default=None, doc="Initial coloring variable", allow_None=True + default=None, doc="Coloring variable", allow_None=True ) datashade = param.Boolean(default=True, doc="Whether to enable datashading") width = param.Integer(default=300, doc="Width of the plot") From 27e1f26780cc0fd98b9735d25cfc657f6e44997a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:30:51 +0000 Subject: [PATCH 07/55] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/hv_anndata/featuremap.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/featuremap.py index 983f9ad..f8e6a60 100644 --- a/src/hv_anndata/featuremap.py +++ b/src/hv_anndata/featuremap.py @@ -175,9 +175,7 @@ class FeatureMapApp(pn.viewable.Viewer): reduction = param.String( default=None, doc="Dimension reduction method", allow_None=True ) - color_by = param.String( - default=None, doc="Coloring variable", allow_None=True - ) + color_by = param.String(default=None, doc="Coloring variable", allow_None=True) datashade = param.Boolean(default=True, doc="Whether to enable datashading") width = param.Integer(default=300, doc="Width of the plot") height = param.Integer(default=300, doc="Height of the plot") From 93ac812151447a32ad5e1df64712d88688df213d Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Mon, 24 Mar 2025 16:38:58 -0700 Subject: [PATCH 08/55] address ruff complaints --- src/hv_anndata/featuremap.py | 416 ++++++++++++++++++++++++++--------- src/hv_anndata/plotting.py | 2 + 2 files changed, 317 insertions(+), 101 deletions(-) diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/featuremap.py index f8e6a60..3abc271 100644 --- a/src/hv_anndata/featuremap.py +++ b/src/hv_anndata/featuremap.py @@ -1,3 +1,5 @@ +"""Interactive visualization of AnnData dimension reductions with HoloViews and Panel.""" + from __future__ import annotations import anndata as ad @@ -9,6 +11,19 @@ import panel as pn import param from panel.reactive import hold +from typing import Any, Dict, List, Optional, Tuple, Union, TypedDict + + +class FeatureMapConfig(TypedDict, total=False): + """Configuration options for feature map plotting.""" + + width: int + height: int + datashading: bool + labels: bool + cont_cmap: str + cat_cmap: list + title: str def create_featuremap_plot( @@ -19,30 +34,51 @@ def create_featuremap_plot( color_var: str, xaxis_label: str, yaxis_label: str, - width: int = 300, - height: int = 300, - datashading: bool = True, - labels: bool = False, - cont_cmap: str = "viridis", - cat_cmap: list = cc.b_glasbey_category10, - title: str = "", + **config: Any, ) -> hv.Element: - """Create a comprehensive feature map plot with options for datashading and labels + """Create a comprehensive feature map plot with options for datashading and labels. Parameters ---------- - - x_data: numpy.ndarray, shape n_obs by n_dimensions - - color_data: numpy.ndarray, shape n_obs color values (categorical or continuous) - - x_dim, y_dim: int, indices to use as x or y data - - color_var: str, name to give the coloring dimension - - xaxis_label, yaxis_label: str, labels for the axes - - width, height: int, dimensions of the plot - - datashading: bool, whether to apply datashader - - labels: bool, whether to overlay labels at median positions - - cont_cmap: str or list, colormap for continuous data - - cat_cmap: str or list, colormap for categorical data + x_data : np.ndarray + Array with shape n_obs by n_dimensions containing coordinates + color_data : np.ndarray + Array with shape n_obs containing color values (categorical or continuous) + x_dim : int + Index to use for x-axis data + y_dim : int + Index to use for y-axis data + color_var : str + Name to give the coloring dimension + xaxis_label : str + Label for the x axis + yaxis_label : str + Label for the y axis + **config : Any + Additional configuration options including: + - width: int, width of the plot (default: 300) + - height: int, height of the plot (default: 300) + - datashading: bool, whether to apply datashader (default: True) + - labels: bool, whether to overlay labels at median positions (default: False) + - cont_cmap: str, colormap for continuous data (default: "viridis") + - cat_cmap: list, colormap for categorical data (default: cc.b_glasbey_category10) + - title: str, plot title (default: "") + Returns + ------- + hv.Element + HoloViews element with the configured plot """ + # Extract config with defaults + width = config.get("width", 300) + height = config.get("height", 300) + datashading = config.get("datashading", True) + labels = config.get("labels", False) + cont_cmap = config.get("cont_cmap", "viridis") + cat_cmap = config.get("cat_cmap", cc.b_glasbey_category10) + title = config.get("title", "") + + # Determine if color data is categorical is_categorical = ( color_data.dtype.name in ["category", "categorical", "bool"] or np.issubdtype(color_data.dtype, np.object_) @@ -52,18 +88,16 @@ def create_featuremap_plot( # Set colormap and plot options based on data type if is_categorical: n_unq_cat = len(np.unique(color_data)) - # hack so that cat cmap doesn't stretch and skip colors + # Use subset of categorical colormap to preserve distinct colors cmap = cat_cmap[:n_unq_cat] colorbar = False - if labels: - show_legend = False - else: - show_legend = True + show_legend = not labels else: cmap = cont_cmap show_legend = False colorbar = True + # Create basic plot plot = hv.Points( (x_data[:, x_dim], x_data[:, y_dim], color_data), [xaxis_label, yaxis_label], @@ -86,75 +120,32 @@ def create_featuremap_plot( # Options for labels label_opts = dict(text_font_size="8pt", text_color="black") - # Apply datashading if requested - if datashading: + # Apply different rendering based on configuration + if not datashading: + # Standard plot without datashading + plot = plot.opts(**plot_opts) + + # Add labels if categorical and requested + if is_categorical and labels: + plot = _add_category_labels( + plot, x_data, color_data, x_dim, y_dim, + xaxis_label, yaxis_label, label_opts + ) + else: + # Apply datashading with different approaches for categorical vs continuous if is_categorical: - # For categorical data, count by category - aggregator = ds.count_cat(color_var) - plot = hd.rasterize(plot, aggregator=aggregator) - plot = hd.dynspread(plot, threshold=0.5) - plot = plot.opts(cmap=cmap, tools=["hover"]) - - if labels: - # Add labels at median positions - unique_categories = np.unique(color_data) - labels_data = [] - for cat in unique_categories: - mask = color_data == cat - median_x = np.median(x_data[mask, x_dim]) - median_y = np.median(x_data[mask, y_dim]) - labels_data.append((median_x, median_y, str(cat))) - labels_element = hv.Labels( - labels_data, [xaxis_label, yaxis_label], "Label" - ).opts(**label_opts) - plot = plot * labels_element - else: - # Create a custom legend for datashaded categorical plot - unique_categories = np.unique(color_data) - color_key = dict( - zip(unique_categories, cmap[: len(unique_categories)], strict=False) - ) - legend_items = [ - hv.Points([0, 0], label=str(cat)).opts(color=color_key[cat], size=0) - for cat in unique_categories - ] - legend = hv.NdOverlay( - { - str(cat): item - for cat, item in zip( - unique_categories, legend_items, strict=False - ) - } - ).opts( - show_legend=True, - legend_position="right", - legend_limit=100, - legend_cols=len(unique_categories) // 10 + 1, - ) - plot = plot * legend + plot = _apply_categorical_datashading( + plot, x_data, color_data, x_dim, y_dim, color_var, cmap, + xaxis_label, yaxis_label, labels, label_opts + ) else: # For continuous data, take the mean aggregator = ds.mean(color_var) plot = hd.rasterize(plot, aggregator=aggregator) plot = hd.dynspread(plot, threshold=0.5) plot = plot.opts(cmap=cmap, colorbar=colorbar) - else: - # Standard plot without datashading - plot = plot.opts(**plot_opts) - if is_categorical and labels: - # Add labels for non-datashaded categorical plot - unique_categories = np.unique(color_data) - labels_data = [] - for cat in unique_categories: - mask = color_data == cat - median_x = np.median(x_data[mask, x_dim]) - median_y = np.median(x_data[mask, y_dim]) - labels_data.append((median_x, median_y, str(cat))) - labels_element = hv.Labels( - labels_data, [xaxis_label, yaxis_label], "Label" - ).opts(**label_opts) - plot = plot * labels_element + # Apply final options to the plot return plot.opts( title=title, tools=["hover"], @@ -164,11 +155,168 @@ def create_featuremap_plot( ) +def _add_category_labels( + plot: hv.Element, + x_data: np.ndarray, + color_data: np.ndarray, + x_dim: int, + y_dim: int, + xaxis_label: str, + yaxis_label: str, + label_opts: Dict[str, Any], +) -> hv.Element: + """Add category labels to a plot. + + Parameters + ---------- + plot : hv.Element + The base plot to add labels to + x_data : np.ndarray + Coordinate data + color_data : np.ndarray + Category data for coloring + x_dim : int + Index for x dimension + y_dim : int + Index for y dimension + xaxis_label : str + X-axis label + yaxis_label : str + Y-axis label + label_opts : Dict[str, Any] + Options for label formatting + + Returns + ------- + hv.Element + Plot with labels added + """ + unique_categories = np.unique(color_data) + labels_data = [] + + for cat in unique_categories: + mask = color_data == cat + if np.any(mask): + median_x = np.median(x_data[mask, x_dim]) + median_y = np.median(x_data[mask, y_dim]) + labels_data.append((median_x, median_y, str(cat))) + + labels_element = hv.Labels( + labels_data, [xaxis_label, yaxis_label], "Label" + ).opts(**label_opts) + + return plot * labels_element + + +def _apply_categorical_datashading( + plot: hv.Element, + x_data: np.ndarray, + color_data: np.ndarray, + x_dim: int, + y_dim: int, + color_var: str, + cmap: Any, + xaxis_label: str, + yaxis_label: str, + labels: bool, + label_opts: Dict[str, Any], +) -> hv.Element: + """Apply datashading to categorical data. + + Parameters + ---------- + plot : hv.Element + The base plot to apply datashading to + x_data : np.ndarray + Coordinate data + color_data : np.ndarray + Category data for coloring + x_dim : int + Index for x dimension + y_dim : int + Index for y dimension + color_var : str + Name of the color variable + cmap : Any + Colormap to use + xaxis_label : str + X-axis label + yaxis_label : str + Y-axis label + labels : bool + Whether to add category labels + label_opts : Dict[str, Any] + Options for label formatting + + Returns + ------- + hv.Element + Datashaded plot with optional labels and legend + """ + # For categorical data, count by category + aggregator = ds.count_cat(color_var) + plot = hd.rasterize(plot, aggregator=aggregator) + plot = hd.dynspread(plot, threshold=0.5) + plot = plot.opts(cmap=cmap, tools=["hover"]) + + # Add either labels or a custom legend + if labels: + plot = _add_category_labels( + plot, x_data, color_data, x_dim, y_dim, + xaxis_label, yaxis_label, label_opts + ) + else: + # Create a custom legend for datashaded categorical plot + unique_categories = np.unique(color_data) + color_key = dict( + zip(unique_categories, cmap[: len(unique_categories)], strict=False) + ) + legend_items = [ + hv.Points([0, 0], label=str(cat)).opts(color=color_key[cat], size=0) + for cat in unique_categories + ] + legend = hv.NdOverlay( + { + str(cat): item + for cat, item in zip( + unique_categories, legend_items, strict=False + ) + } + ).opts( + show_legend=True, + legend_position="right", + legend_limit=100, + legend_cols=len(unique_categories) // 10 + 1, + ) + plot = plot * legend + + return plot + + class FeatureMapApp(pn.viewable.Viewer): - """Create an interactive feature map application for exploring AnnData objects. + """Interactive feature map application for exploring AnnData objects. This application provides widgets to select dimensionality reduction methods, dimensions for x and y axes, coloring variables, and display options. + + Parameters + ---------- + adata : ad.AnnData + AnnData object to visualize + reduction : Optional[str] + Initial dimension reduction method to use + color_by : Optional[str] + Initial variable to use for coloring + datashade : bool + Whether to enable datashading + width : int + Width of the plot + height : int + Height of the plot + labels : bool + Whether to show labels + show_widgets : bool + Whether to show control widgets """ adata = param.ClassSelector(class_=ad.AnnData, doc="AnnData object to visualize") @@ -182,12 +330,12 @@ class FeatureMapApp(pn.viewable.Viewer): labels = param.Boolean(default=False, doc="Whether to show labels") show_widgets = param.Boolean(default=True, doc="Whether to show control widgets") - def __init__(self, **params): + def __init__(self, **params: Any) -> None: + """Initialize the FeatureMapApp with the given parameters.""" super().__init__(**params) self.dr_options = list(self.adata.obsm.keys()) if not self.reduction: self.reduction = self.dr_options[0] - # self.default_dr = reduction or dr_options[0] self.color_options = list(self.adata.obs.columns) if not self.color_by: @@ -196,19 +344,70 @@ def __init__(self, **params): if "cell_type" in self.color_options else self.color_options[0] ) - # self.default_color = color_by or color_options[0] - def get_reduction_label(self, dr_key): + def get_reduction_label(self, dr_key: str) -> str: + """Get a display label for a dimension reduction key. + + Parameters + ---------- + dr_key : str + The dimension reduction key + + Returns + ------- + str + A formatted label for display + """ return dr_key.split("_")[1].upper() if "_" in dr_key else dr_key.upper() - def get_dim_labels(self, dr_key): + def get_dim_labels(self, dr_key: str) -> List[str]: + """Get labels for each dimension in a reduction method. + + Parameters + ---------- + dr_key : str + The dimension reduction key + + Returns + ------- + List[str] + List of labels for each dimension + """ dr_label = self.get_reduction_label(dr_key) num_dims = self.adata.obsm[dr_key].shape[1] return [f"{dr_label}{i + 1}" for i in range(num_dims)] def create_plot( - self, dr_key, x_value, y_value, color_value, datashade_value, label_value - ): + self, + dr_key: str, + x_value: str, + y_value: str, + color_value: str, + datashade_value: bool, + label_value: bool + ) -> pn.viewable.Viewable: + """Create a feature map plot with the specified parameters. + + Parameters + ---------- + dr_key : str + Dimensionality reduction key + x_value : str + X-axis dimension label + y_value : str + Y-axis dimension label + color_value : str + Variable to use for coloring + datashade_value : bool + Whether to enable datashading + label_value : bool + Whether to show labels + + Returns + ------- + pn.viewable.Viewable + The plot or an error message + """ x_data = self.adata.obsm[dr_key] dr_label = self.get_reduction_label(dr_key) @@ -240,6 +439,15 @@ def create_plot( color_data = np.zeros(self.adata.n_obs) print(f"Warning: Could not find {color_value} in obs or var") + # Configure the plot + config = FeatureMapConfig( + width=self.width, + height=self.height, + datashading=datashade_value, + labels=label_value, + title=f"{dr_label}.{color_value}", + ) + return create_featuremap_plot( x_data, color_data, @@ -248,14 +456,17 @@ def create_plot( color_value, x_value, y_value, - width=self.width, - height=self.height, - datashading=datashade_value, - labels=label_value, - title=f"{dr_label}.{color_value}", + **config, ) - def __panel__(self): + def __panel__(self) -> pn.viewable.Viewable: + """Create the Panel application layout. + + Returns + ------- + pn.viewable.Viewable + The assembled panel application + """ # Widgets dr_select = pn.widgets.Select.from_param( self.param.reduction, options=self.dr_options @@ -277,15 +488,16 @@ def __panel__(self): self.param.labels, name="Overlay Labels For Categorical Coloring" ) - # reset dim options when reduction selection changes + # Reset dimension options when reduction selection changes @hold() - def reset_dimension_options(event): + def reset_dimension_options(event: Any) -> None: new_dims = self.get_dim_labels(event.new) x_axis.param.update(options=new_dims, value=new_dims[0]) y_axis.param.update(options=new_dims, value=new_dims[1]) dr_select.param.watch(reset_dimension_options, "value") + # Bind the plot creation to widget values plot_pane = pn.bind( self.create_plot, dr_key=dr_select, @@ -296,6 +508,7 @@ def reset_dimension_options(event): label_value=label_switch, ) + # Create widget box widgets = pn.WidgetBox( dr_select, x_axis, @@ -306,4 +519,5 @@ def reset_dimension_options(event): visible=self.show_widgets, ) - return pn.Row(widgets, plot_pane) + # Return the assembled layout + return pn.Row(widgets, plot_pane) \ No newline at end of file diff --git a/src/hv_anndata/plotting.py b/src/hv_anndata/plotting.py index 5e8d50b..0b7c8cf 100644 --- a/src/hv_anndata/plotting.py +++ b/src/hv_anndata/plotting.py @@ -1,3 +1,5 @@ +"""HoloViz plotting using AnnData as input.""" + from __future__ import annotations from itertools import chain From caecadc2a9c75d49ccf2bfaa16145faa3adfa755 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:39:26 +0000 Subject: [PATCH 09/55] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/hv_anndata/featuremap.py | 102 +++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 41 deletions(-) diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/featuremap.py index 3abc271..53a83f6 100644 --- a/src/hv_anndata/featuremap.py +++ b/src/hv_anndata/featuremap.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any, TypedDict + import anndata as ad import colorcet as cc import datashader as ds @@ -11,7 +13,6 @@ import panel as pn import param from panel.reactive import hold -from typing import Any, Dict, List, Optional, Tuple, Union, TypedDict class FeatureMapConfig(TypedDict, total=False): @@ -68,6 +69,7 @@ def create_featuremap_plot( ------- hv.Element HoloViews element with the configured plot + """ # Extract config with defaults width = config.get("width", 300) @@ -124,26 +126,40 @@ def create_featuremap_plot( if not datashading: # Standard plot without datashading plot = plot.opts(**plot_opts) - + # Add labels if categorical and requested if is_categorical and labels: plot = _add_category_labels( - plot, x_data, color_data, x_dim, y_dim, - xaxis_label, yaxis_label, label_opts + plot, + x_data, + color_data, + x_dim, + y_dim, + xaxis_label, + yaxis_label, + label_opts, ) + # Apply datashading with different approaches for categorical vs continuous + elif is_categorical: + plot = _apply_categorical_datashading( + plot, + x_data, + color_data, + x_dim, + y_dim, + color_var, + cmap, + xaxis_label, + yaxis_label, + labels, + label_opts, + ) else: - # Apply datashading with different approaches for categorical vs continuous - if is_categorical: - plot = _apply_categorical_datashading( - plot, x_data, color_data, x_dim, y_dim, color_var, cmap, - xaxis_label, yaxis_label, labels, label_opts - ) - else: - # For continuous data, take the mean - aggregator = ds.mean(color_var) - plot = hd.rasterize(plot, aggregator=aggregator) - plot = hd.dynspread(plot, threshold=0.5) - plot = plot.opts(cmap=cmap, colorbar=colorbar) + # For continuous data, take the mean + aggregator = ds.mean(color_var) + plot = hd.rasterize(plot, aggregator=aggregator) + plot = hd.dynspread(plot, threshold=0.5) + plot = plot.opts(cmap=cmap, colorbar=colorbar) # Apply final options to the plot return plot.opts( @@ -163,7 +179,7 @@ def _add_category_labels( y_dim: int, xaxis_label: str, yaxis_label: str, - label_opts: Dict[str, Any], + label_opts: dict[str, Any], ) -> hv.Element: """Add category labels to a plot. @@ -190,21 +206,22 @@ def _add_category_labels( ------- hv.Element Plot with labels added + """ unique_categories = np.unique(color_data) labels_data = [] - + for cat in unique_categories: mask = color_data == cat if np.any(mask): median_x = np.median(x_data[mask, x_dim]) median_y = np.median(x_data[mask, y_dim]) labels_data.append((median_x, median_y, str(cat))) - - labels_element = hv.Labels( - labels_data, [xaxis_label, yaxis_label], "Label" - ).opts(**label_opts) - + + labels_element = hv.Labels(labels_data, [xaxis_label, yaxis_label], "Label").opts( + **label_opts + ) + return plot * labels_element @@ -219,7 +236,7 @@ def _apply_categorical_datashading( xaxis_label: str, yaxis_label: str, labels: bool, - label_opts: Dict[str, Any], + label_opts: dict[str, Any], ) -> hv.Element: """Apply datashading to categorical data. @@ -252,18 +269,18 @@ def _apply_categorical_datashading( ------- hv.Element Datashaded plot with optional labels and legend + """ # For categorical data, count by category aggregator = ds.count_cat(color_var) plot = hd.rasterize(plot, aggregator=aggregator) plot = hd.dynspread(plot, threshold=0.5) plot = plot.opts(cmap=cmap, tools=["hover"]) - + # Add either labels or a custom legend if labels: plot = _add_category_labels( - plot, x_data, color_data, x_dim, y_dim, - xaxis_label, yaxis_label, label_opts + plot, x_data, color_data, x_dim, y_dim, xaxis_label, yaxis_label, label_opts ) else: # Create a custom legend for datashaded categorical plot @@ -278,9 +295,7 @@ def _apply_categorical_datashading( legend = hv.NdOverlay( { str(cat): item - for cat, item in zip( - unique_categories, legend_items, strict=False - ) + for cat, item in zip(unique_categories, legend_items, strict=False) } ).opts( show_legend=True, @@ -289,7 +304,7 @@ def _apply_categorical_datashading( legend_cols=len(unique_categories) // 10 + 1, ) plot = plot * legend - + return plot @@ -298,7 +313,7 @@ class FeatureMapApp(pn.viewable.Viewer): This application provides widgets to select dimensionality reduction methods, dimensions for x and y axes, coloring variables, and display options. - + Parameters ---------- adata : ad.AnnData @@ -317,6 +332,7 @@ class FeatureMapApp(pn.viewable.Viewer): Whether to show labels show_widgets : bool Whether to show control widgets + """ adata = param.ClassSelector(class_=ad.AnnData, doc="AnnData object to visualize") @@ -357,10 +373,11 @@ def get_reduction_label(self, dr_key: str) -> str: ------- str A formatted label for display + """ return dr_key.split("_")[1].upper() if "_" in dr_key else dr_key.upper() - def get_dim_labels(self, dr_key: str) -> List[str]: + def get_dim_labels(self, dr_key: str) -> list[str]: """Get labels for each dimension in a reduction method. Parameters @@ -372,19 +389,20 @@ def get_dim_labels(self, dr_key: str) -> List[str]: ------- List[str] List of labels for each dimension + """ dr_label = self.get_reduction_label(dr_key) num_dims = self.adata.obsm[dr_key].shape[1] return [f"{dr_label}{i + 1}" for i in range(num_dims)] def create_plot( - self, - dr_key: str, - x_value: str, - y_value: str, - color_value: str, - datashade_value: bool, - label_value: bool + self, + dr_key: str, + x_value: str, + y_value: str, + color_value: str, + datashade_value: bool, + label_value: bool, ) -> pn.viewable.Viewable: """Create a feature map plot with the specified parameters. @@ -407,6 +425,7 @@ def create_plot( ------- pn.viewable.Viewable The plot or an error message + """ x_data = self.adata.obsm[dr_key] dr_label = self.get_reduction_label(dr_key) @@ -466,6 +485,7 @@ def __panel__(self) -> pn.viewable.Viewable: ------- pn.viewable.Viewable The assembled panel application + """ # Widgets dr_select = pn.widgets.Select.from_param( @@ -520,4 +540,4 @@ def reset_dimension_options(event: Any) -> None: ) # Return the assembled layout - return pn.Row(widgets, plot_pane) \ No newline at end of file + return pn.Row(widgets, plot_pane) From ef9b74aac7727742132c6f7799ae8900f3723c75 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 27 Mar 2025 17:24:34 +0100 Subject: [PATCH 10/55] ruff fixes --- src/hv_anndata/featuremap.py | 90 ++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/featuremap.py index 53a83f6..175bdb6 100644 --- a/src/hv_anndata/featuremap.py +++ b/src/hv_anndata/featuremap.py @@ -1,8 +1,9 @@ -"""Interactive visualization of AnnData dimension reductions with HoloViews and Panel.""" +"""Interactive vizualization of AnnData dimension reductions with HoloViews and Panel.""" # noqa: E501 from __future__ import annotations -from typing import Any, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict, Unpack +from warnings import warn import anndata as ad import colorcet as cc @@ -14,17 +15,27 @@ import param from panel.reactive import hold +if TYPE_CHECKING: + from collections.abc import Sequence + class FeatureMapConfig(TypedDict, total=False): """Configuration options for feature map plotting.""" width: int + """width of the plot (default: 300)""" height: int + """height of the plot (default: 300)""" datashading: bool + """whether to apply datashader (default: True)""" labels: bool + """whether to overlay labels at median positions (default: False)""" cont_cmap: str + """colormap for continuous data (default: "viridis")""" cat_cmap: list + """colormap for categorical data (default: cc.b_glasbey_category10)""" title: str + """plot title (default: "")""" def create_featuremap_plot( @@ -35,7 +46,7 @@ def create_featuremap_plot( color_var: str, xaxis_label: str, yaxis_label: str, - **config: Any, + **config: Unpack[FeatureMapConfig], ) -> hv.Element: """Create a comprehensive feature map plot with options for datashading and labels. @@ -56,14 +67,7 @@ def create_featuremap_plot( yaxis_label : str Label for the y axis **config : Any - Additional configuration options including: - - width: int, width of the plot (default: 300) - - height: int, height of the plot (default: 300) - - datashading: bool, whether to apply datashader (default: True) - - labels: bool, whether to overlay labels at median positions (default: False) - - cont_cmap: str, colormap for continuous data (default: "viridis") - - cat_cmap: list, colormap for categorical data (default: cc.b_glasbey_category10) - - title: str, plot title (default: "") + Additional configuration options including, see :class:`FeatureMapConfig`. Returns ------- @@ -143,16 +147,16 @@ def create_featuremap_plot( elif is_categorical: plot = _apply_categorical_datashading( plot, - x_data, - color_data, - x_dim, - y_dim, - color_var, - cmap, - xaxis_label, - yaxis_label, - labels, - label_opts, + x_data=x_data, + color_data=color_data, + x_dim=x_dim, + y_dim=y_dim, + color_var=color_var, + cmap=cmap, + xaxis_label=xaxis_label, + yaxis_label=yaxis_label, + labels=labels, + label_opts=label_opts, ) else: # For continuous data, take the mean @@ -171,7 +175,7 @@ def create_featuremap_plot( ) -def _add_category_labels( +def _add_category_labels( # noqa: PLR0913 plot: hv.Element, x_data: np.ndarray, color_data: np.ndarray, @@ -225,14 +229,15 @@ def _add_category_labels( return plot * labels_element -def _apply_categorical_datashading( +def _apply_categorical_datashading( # noqa: PLR0913 plot: hv.Element, + *, x_data: np.ndarray, color_data: np.ndarray, x_dim: int, y_dim: int, color_var: str, - cmap: Any, + cmap: Sequence[str], xaxis_label: str, yaxis_label: str, labels: bool, @@ -242,33 +247,32 @@ def _apply_categorical_datashading( Parameters ---------- - plot : hv.Element + plot The base plot to apply datashading to - x_data : np.ndarray + x_data Coordinate data - color_data : np.ndarray + color_data Category data for coloring - x_dim : int + x_dim Index for x dimension - y_dim : int + y_dim Index for y dimension - color_var : str + color_var Name of the color variable - cmap : Any + cmap Colormap to use - xaxis_label : str + xaxis_label X-axis label - yaxis_label : str + yaxis_label Y-axis label - labels : bool + labels Whether to add category labels - label_opts : Dict[str, Any] + label_opts Options for label formatting Returns ------- - hv.Element - Datashaded plot with optional labels and legend + Datashaded plot with optional labels and legend """ # For categorical data, count by category @@ -346,7 +350,7 @@ class FeatureMapApp(pn.viewable.Viewer): labels = param.Boolean(default=False, doc="Whether to show labels") show_widgets = param.Boolean(default=True, doc="Whether to show control widgets") - def __init__(self, **params: Any) -> None: + def __init__(self, **params: object) -> None: """Initialize the FeatureMapApp with the given parameters.""" super().__init__(**params) self.dr_options = list(self.adata.obsm.keys()) @@ -397,6 +401,7 @@ def get_dim_labels(self, dr_key: str) -> list[str]: def create_plot( self, + *, dr_key: str, x_value: str, y_value: str, @@ -441,7 +446,8 @@ def create_plot( y_dim = int(y_value.replace(dr_label, "")) - 1 except (ValueError, AttributeError): return pn.pane.Markdown( - f"Error parsing dimensions. Make sure to select valid {dr_label} dimensions." + f"Error parsing dimensions. " + f"Make sure to select valid {dr_label} dimensions." ) # Get color data from .obs or X cols @@ -456,7 +462,9 @@ def create_plot( ) except (KeyError, ValueError): color_data = np.zeros(self.adata.n_obs) - print(f"Warning: Could not find {color_value} in obs or var") + warn( + f"Warning: Could not find {color_value} in obs or var", stacklevel=2 + ) # Configure the plot config = FeatureMapConfig( @@ -510,7 +518,7 @@ def __panel__(self) -> pn.viewable.Viewable: # Reset dimension options when reduction selection changes @hold() - def reset_dimension_options(event: Any) -> None: + def reset_dimension_options(event: object) -> None: new_dims = self.get_dim_labels(event.new) x_axis.param.update(options=new_dims, value=new_dims[0]) y_axis.param.update(options=new_dims, value=new_dims[1]) From 8f45f25491bbcb1a903bec8838e1c1851c6ce17c Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 27 Mar 2025 17:30:52 +0100 Subject: [PATCH 11/55] remove redundant types --- src/hv_anndata/featuremap.py | 104 +++++++++++++++++------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/featuremap.py index 175bdb6..1c59329 100644 --- a/src/hv_anndata/featuremap.py +++ b/src/hv_anndata/featuremap.py @@ -52,27 +52,26 @@ def create_featuremap_plot( Parameters ---------- - x_data : np.ndarray + x_data Array with shape n_obs by n_dimensions containing coordinates - color_data : np.ndarray + color_data Array with shape n_obs containing color values (categorical or continuous) - x_dim : int + x_dim Index to use for x-axis data - y_dim : int + y_dim Index to use for y-axis data - color_var : str + color_var Name to give the coloring dimension - xaxis_label : str + xaxis_label Label for the x axis - yaxis_label : str + yaxis_label Label for the y axis - **config : Any + **config Additional configuration options including, see :class:`FeatureMapConfig`. Returns ------- - hv.Element - HoloViews element with the configured plot + HoloViews element with the configured plot """ # Extract config with defaults @@ -189,27 +188,26 @@ def _add_category_labels( # noqa: PLR0913 Parameters ---------- - plot : hv.Element + plot The base plot to add labels to - x_data : np.ndarray + x_data Coordinate data - color_data : np.ndarray + color_data Category data for coloring - x_dim : int + x_dim Index for x dimension - y_dim : int + y_dim Index for y dimension - xaxis_label : str + xaxis_label X-axis label - yaxis_label : str + yaxis_label Y-axis label - label_opts : Dict[str, Any] + label_opts Options for label formatting Returns ------- - hv.Element - Plot with labels added + Plot with labels added """ unique_categories = np.unique(color_data) @@ -320,35 +318,41 @@ class FeatureMapApp(pn.viewable.Viewer): Parameters ---------- - adata : ad.AnnData + adata AnnData object to visualize - reduction : Optional[str] + reduction Initial dimension reduction method to use - color_by : Optional[str] + color_by Initial variable to use for coloring - datashade : bool + datashade Whether to enable datashading - width : int + width Width of the plot - height : int + height Height of the plot - labels : bool + labels Whether to show labels - show_widgets : bool + show_widgets Whether to show control widgets """ - adata = param.ClassSelector(class_=ad.AnnData, doc="AnnData object to visualize") - reduction = param.String( + adata: ad.AnnData = param.ClassSelector( # type: ignore[assignment] + class_=ad.AnnData, doc="AnnData object to visualize" + ) + reduction: str | None = param.String( # type: ignore[assignment] default=None, doc="Dimension reduction method", allow_None=True ) - color_by = param.String(default=None, doc="Coloring variable", allow_None=True) - datashade = param.Boolean(default=True, doc="Whether to enable datashading") - width = param.Integer(default=300, doc="Width of the plot") - height = param.Integer(default=300, doc="Height of the plot") - labels = param.Boolean(default=False, doc="Whether to show labels") - show_widgets = param.Boolean(default=True, doc="Whether to show control widgets") + color_by: str | None = param.String( # type: ignore[assignment] + default=None, doc="Coloring variable", allow_None=True + ) + datashade: bool = param.Boolean(default=True, doc="Whether to enable datashading") # type: ignore[assignment] + width: int = param.Integer(default=300, doc="Width of the plot") # type: ignore[assignment] + height: int = param.Integer(default=300, doc="Height of the plot") # type: ignore[assignment] + labels: bool = param.Boolean(default=False, doc="Whether to show labels") # type: ignore[assignment] + show_widgets: bool = param.Boolean( # type: ignore[assignment] + default=True, doc="Whether to show control widgets" + ) def __init__(self, **params: object) -> None: """Initialize the FeatureMapApp with the given parameters.""" @@ -370,13 +374,12 @@ def get_reduction_label(self, dr_key: str) -> str: Parameters ---------- - dr_key : str + dr_key The dimension reduction key Returns ------- - str - A formatted label for display + A formatted label for display """ return dr_key.split("_")[1].upper() if "_" in dr_key else dr_key.upper() @@ -386,13 +389,12 @@ def get_dim_labels(self, dr_key: str) -> list[str]: Parameters ---------- - dr_key : str + dr_key The dimension reduction key Returns ------- - List[str] - List of labels for each dimension + List of labels for each dimension """ dr_label = self.get_reduction_label(dr_key) @@ -413,23 +415,22 @@ def create_plot( Parameters ---------- - dr_key : str + dr_key Dimensionality reduction key - x_value : str + x_value X-axis dimension label - y_value : str + y_value Y-axis dimension label - color_value : str + color_value Variable to use for coloring - datashade_value : bool + datashade_value Whether to enable datashading - label_value : bool + label_value Whether to show labels Returns ------- - pn.viewable.Viewable - The plot or an error message + The plot or an error message """ x_data = self.adata.obsm[dr_key] @@ -491,8 +492,7 @@ def __panel__(self) -> pn.viewable.Viewable: Returns ------- - pn.viewable.Viewable - The assembled panel application + The assembled panel application """ # Widgets From 935867775e7c072101bdcc005ff1907206008dab Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Wed, 23 Apr 2025 16:54:03 -0700 Subject: [PATCH 12/55] rename FeatureMap to ManifoldMap --- src/hv_anndata/__init__.py | 6 ++--- .../{featuremap.py => manifoldmap.py} | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) rename src/hv_anndata/{featuremap.py => manifoldmap.py} (95%) diff --git a/src/hv_anndata/__init__.py b/src/hv_anndata/__init__.py index 2695393..072f77b 100644 --- a/src/hv_anndata/__init__.py +++ b/src/hv_anndata/__init__.py @@ -2,14 +2,14 @@ from __future__ import annotations -from .featuremap import FeatureMapApp, create_featuremap_plot +from .manifoldmap import ManifoldMap, create_manifoldmap_plot from .interface import AnnDataInterface, register from .plotting import Dotmap __all__ = [ "AnnDataInterface", "Dotmap", - "FeatureMapApp", - "create_featuremap_plot", + "ManifoldMap", + "create_manifoldmap_plot", "register", ] diff --git a/src/hv_anndata/featuremap.py b/src/hv_anndata/manifoldmap.py similarity index 95% rename from src/hv_anndata/featuremap.py rename to src/hv_anndata/manifoldmap.py index 1c59329..15cda69 100644 --- a/src/hv_anndata/featuremap.py +++ b/src/hv_anndata/manifoldmap.py @@ -19,8 +19,8 @@ from collections.abc import Sequence -class FeatureMapConfig(TypedDict, total=False): - """Configuration options for feature map plotting.""" +class ManifoldMapConfig(TypedDict, total=False): + """Configuration options for manifold map plotting.""" width: int """width of the plot (default: 300)""" @@ -38,7 +38,7 @@ class FeatureMapConfig(TypedDict, total=False): """plot title (default: "")""" -def create_featuremap_plot( +def create_manifoldmap_plot( x_data: np.ndarray, color_data: np.ndarray, x_dim: int, @@ -48,7 +48,7 @@ def create_featuremap_plot( yaxis_label: str, **config: Unpack[FeatureMapConfig], ) -> hv.Element: - """Create a comprehensive feature map plot with options for datashading and labels. + """Create a comprehensive manifold map plot with options for datashading and labels. Parameters ---------- @@ -310,8 +310,8 @@ def _apply_categorical_datashading( # noqa: PLR0913 return plot -class FeatureMapApp(pn.viewable.Viewer): - """Interactive feature map application for exploring AnnData objects. +class ManifoldMap(pn.viewable.Viewer): + """Interactive manifold map application for exploring AnnData objects. This application provides widgets to select dimensionality reduction methods, dimensions for x and y axes, coloring variables, and display options. @@ -354,8 +354,13 @@ class FeatureMapApp(pn.viewable.Viewer): default=True, doc="Whether to show control widgets" ) +<<<<<<< Updated upstream:src/hv_anndata/featuremap.py def __init__(self, **params: object) -> None: """Initialize the FeatureMapApp with the given parameters.""" +======= + def __init__(self, **params: Any) -> None: + """Initialize the ManifoldMap with the given parameters.""" +>>>>>>> Stashed changes:src/hv_anndata/manifoldmap.py super().__init__(**params) self.dr_options = list(self.adata.obsm.keys()) if not self.reduction: @@ -411,7 +416,7 @@ def create_plot( datashade_value: bool, label_value: bool, ) -> pn.viewable.Viewable: - """Create a feature map plot with the specified parameters. + """Create a manifold map plot with the specified parameters. Parameters ---------- @@ -468,7 +473,7 @@ def create_plot( ) # Configure the plot - config = FeatureMapConfig( + config = ManifoldMapConfig( width=self.width, height=self.height, datashading=datashade_value, @@ -476,7 +481,7 @@ def create_plot( title=f"{dr_label}.{color_value}", ) - return create_featuremap_plot( + return create_manifoldmap_plot( x_data, color_data, x_dim, From 5d41cb7065ad78f038c52c338668a41b3f4352ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 23 Apr 2025 23:54:13 +0000 Subject: [PATCH 13/55] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/hv_anndata/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hv_anndata/__init__.py b/src/hv_anndata/__init__.py index 072f77b..e0bfc22 100644 --- a/src/hv_anndata/__init__.py +++ b/src/hv_anndata/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from .manifoldmap import ManifoldMap, create_manifoldmap_plot from .interface import AnnDataInterface, register +from .manifoldmap import ManifoldMap, create_manifoldmap_plot from .plotting import Dotmap __all__ = [ From f691154841c8139c907ce83e05b9882ae78c8708 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Wed, 23 Apr 2025 17:01:50 -0700 Subject: [PATCH 14/55] fix precommit checks --- src/hv_anndata/manifoldmap.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 15cda69..3f420a4 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -46,7 +46,7 @@ def create_manifoldmap_plot( color_var: str, xaxis_label: str, yaxis_label: str, - **config: Unpack[FeatureMapConfig], + **config: Unpack[ManifoldMapConfig], ) -> hv.Element: """Create a comprehensive manifold map plot with options for datashading and labels. @@ -67,7 +67,7 @@ def create_manifoldmap_plot( yaxis_label Label for the y axis **config - Additional configuration options including, see :class:`FeatureMapConfig`. + Additional configuration options including, see :class:`ManifoldMapConfig`. Returns ------- @@ -354,13 +354,8 @@ class ManifoldMap(pn.viewable.Viewer): default=True, doc="Whether to show control widgets" ) -<<<<<<< Updated upstream:src/hv_anndata/featuremap.py def __init__(self, **params: object) -> None: - """Initialize the FeatureMapApp with the given parameters.""" -======= - def __init__(self, **params: Any) -> None: - """Initialize the ManifoldMap with the given parameters.""" ->>>>>>> Stashed changes:src/hv_anndata/manifoldmap.py + """Initialize the ManifoldMapApp with the given parameters.""" super().__init__(**params) self.dr_options = list(self.adata.obsm.keys()) if not self.reduction: From df22b382ff6da2103410ce28c431720c4249ff72 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Wed, 23 Apr 2025 17:07:47 -0700 Subject: [PATCH 15/55] fix precommit biomejs checks --- biome.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index f47c62f..028d94f 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,8 +1,8 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.0.0-beta.1/schema.json", "overrides": [ { - "include": ["./.vscode/*.json", "**/*.jsonc"], + "includes": ["./.vscode/*.json", "**/*.jsonc"], "json": { "formatter": { "trailingCommas": "all", From f87fe769ec444b6bee1f4e5b3cd90a5089c202ac Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Wed, 23 Apr 2025 17:09:42 -0700 Subject: [PATCH 16/55] reverting biome config changes --- biome.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index 028d94f..f47c62f 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,8 +1,8 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.0-beta.1/schema.json", + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "overrides": [ { - "includes": ["./.vscode/*.json", "**/*.jsonc"], + "include": ["./.vscode/*.json", "**/*.jsonc"], "json": { "formatter": { "trailingCommas": "all", From 9e4a71c9d195166d0d14497659f3a2a4e3250129 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Wed, 23 Apr 2025 17:23:45 -0700 Subject: [PATCH 17/55] fix biome precommits --- .vscode/extensions.json | 4 ++-- .vscode/settings.json | 13 ++++++------- biome.jsonc | 4 ++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ad8ced7..b1c0f05 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,6 @@ "tamasfe.even-better-toml", "charliermarsh.ruff", "biomejs.biome", - "ms-python.python", - ], + "ms-python.python" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b07d53..5a6a4c2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,24 +1,23 @@ { "[json][jsonc][toml][python]": { - "editor.formatOnSave": true, + "editor.formatOnSave": true }, "[json][jsonc]": { - "editor.defaultFormatter": "biomejs.biome", + "editor.defaultFormatter": "biomejs.biome" }, "[toml]": { - "editor.defaultFormatter": "tamasfe.even-better-toml", + "editor.defaultFormatter": "tamasfe.even-better-toml" }, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.codeActionsOnSave": { "source.fixAll": "always", - "source.organizeImports": "always", - }, + "source.organizeImports": "always" + } }, "python.analysis.typeCheckingMode": "basic", "python.testing.pytestArgs": ["--color=yes", "-vv"], "python.testing.pytestEnabled": true, - // mirror settings from pyproject-fmt "evenBetterToml.formatter.compactArrays": false, - "evenBetterToml.formatter.arrayAutoCollapse": false, + "evenBetterToml.formatter.arrayAutoCollapse": false } diff --git a/biome.jsonc b/biome.jsonc index f47c62f..028d94f 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,8 +1,8 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.0.0-beta.1/schema.json", "overrides": [ { - "include": ["./.vscode/*.json", "**/*.jsonc"], + "includes": ["./.vscode/*.json", "**/*.jsonc"], "json": { "formatter": { "trailingCommas": "all", From 8244cca465270743993599200442c8a8a15469ee Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Wed, 23 Apr 2025 17:26:50 -0700 Subject: [PATCH 18/55] ignore deprecation warnings --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8880335..11113ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,10 @@ lint.pylint.max-positional-args = 3 [tool.pytest.ini_options] addopts = [ "--import-mode=importlib", "--strict-markers" ] -filterwarnings = [ "error" ] +filterwarnings = [ + "error", + "ignore::DeprecationWarning:distutils.*", +] [tool.coverage.run] source_pkgs = [ "hv_anndata", "tests" ] From 062be9595ad0f60deb2d67b5751789f12d0c5af5 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Wed, 23 Apr 2025 17:33:07 -0700 Subject: [PATCH 19/55] ignore deprecation warnings attempt 2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 11113ac..74585d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,8 +81,8 @@ lint.pylint.max-positional-args = 3 [tool.pytest.ini_options] addopts = [ "--import-mode=importlib", "--strict-markers" ] filterwarnings = [ - "error", "ignore::DeprecationWarning:distutils.*", + "error", ] [tool.coverage.run] From 77a0ba9be0f097858995ae84d0673597c92adb06 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Wed, 23 Apr 2025 17:39:42 -0700 Subject: [PATCH 20/55] do not treat warnings as errors --- pyproject.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 74585d7..4e7f518 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,10 +80,9 @@ lint.pylint.max-positional-args = 3 [tool.pytest.ini_options] addopts = [ "--import-mode=importlib", "--strict-markers" ] -filterwarnings = [ - "ignore::DeprecationWarning:distutils.*", - "error", -] +# filterwarnings = [ +# "error", +# ] [tool.coverage.run] source_pkgs = [ "hv_anndata", "tests" ] From 805b941c3b230d2c956b2d0573b9554367b03f29 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Thu, 24 Apr 2025 10:52:44 -0700 Subject: [PATCH 21/55] add lasso select --- src/hv_anndata/manifoldmap.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 3f420a4..8b74425 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -117,7 +117,7 @@ def create_manifoldmap_plot( alpha=0.5, colorbar=colorbar, padding=0, - tools=["hover"], + tools=["hover", "box_select", "lasso_select"], show_legend=show_legend, legend_position="right", ) @@ -167,7 +167,7 @@ def create_manifoldmap_plot( # Apply final options to the plot return plot.opts( title=title, - tools=["hover"], + tools=["hover", "box_select", "lasso_select"], show_legend=show_legend, frame_width=width, frame_height=height, @@ -277,7 +277,7 @@ def _apply_categorical_datashading( # noqa: PLR0913 aggregator = ds.count_cat(color_var) plot = hd.rasterize(plot, aggregator=aggregator) plot = hd.dynspread(plot, threshold=0.5) - plot = plot.opts(cmap=cmap, tools=["hover"]) + plot = plot.opts(cmap=cmap, tools=["hover", "box_select", "lasso_select"]) # Add either labels or a custom legend if labels: From e707c6f3ced19a6373d6aa39633813ee351e6e70 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 2 May 2025 17:33:29 +0200 Subject: [PATCH 22/55] add tests --- tests/test_manifold.py | 174 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/test_manifold.py diff --git a/tests/test_manifold.py b/tests/test_manifold.py new file mode 100644 index 0000000..21fe7c7 --- /dev/null +++ b/tests/test_manifold.py @@ -0,0 +1,174 @@ +from unittest.mock import patch + +import anndata as ad +import colorcet as cc +import numpy as np +import pandas as pd +import panel as pn +import pytest + +from hv_anndata.manifoldmap import ManifoldMap, create_manifoldmap_plot + + +@pytest.fixture +def sadata(): + n_obs = 10 + n_vars = 5 + n_dims = 2 + + X = np.random.rand(n_obs, n_vars) + obsm = { + "X_pca": np.random.rand(n_obs, n_dims), + "X_umap": np.random.rand(n_obs, n_dims), + } + obs = pd.DataFrame( + { + "cell_type": ["A", "B"] * (n_obs // 2), + "expression_level": np.random.rand(n_obs), + }, + index=[f"cell_{i}" for i in range(n_obs)], + ) + var = pd.DataFrame( + index=[f"gene_{i}" for i in range(n_vars)], + ) + return ad.AnnData(X=X, obs=obs, obsm=obsm, var=var) + + +@pytest.mark.usefixtures("bokeh_backend") +@pytest.mark.parametrize('color_kind', ['categorical', 'continuous']) +def test_create_manifoldmap_plot_no_datashading(sadata, color_kind): + if color_kind == 'categorical': + color_var = 'cell_type' + elif color_kind == 'continuous': + color_var = 'expression_level' + plot = create_manifoldmap_plot( + sadata.obsm['X_umap'], + sadata.obs[color_var].values, + 0, + 1, + color_var, + 'UMAP1', + 'UMAP2', + datashading=False, + ) + assert plot.kdims == ['UMAP1', 'UMAP2'] + assert plot.vdims == [color_var] + plot_opts = plot.opts.get('plot').kwargs + style_opts = plot.opts.get('style').kwargs + assert style_opts['color'] == color_var + assert style_opts['size'] == 1 + assert style_opts['alpha'] == 0.5 + assert plot_opts['padding'] == 0 + assert plot_opts['tools'] == ['hover', 'box_select', 'lasso_select'] + assert plot_opts['legend_position'] == 'right' + assert plot_opts['frame_width'] == 300 + assert plot_opts['frame_height'] == 300 + + if color_kind == 'categorical': + assert style_opts['cmap'] == cc.b_glasbey_category10[:2] == ['#1f77b3', '#ff7e0e'] + assert plot_opts['show_legend'] is True + assert plot_opts['colorbar'] is False + elif color_kind == 'continuous': + assert style_opts['cmap'] == 'viridis' + assert plot_opts['show_legend'] is False + assert plot_opts['colorbar'] is True + + +@pytest.mark.usefixtures("bokeh_backend") +@pytest.mark.parametrize('color_kind', ['categorical', 'continuous']) +def test_create_manifoldmap_plot_datashading(sadata, color_kind): + if color_kind == 'categorical': + color_var = 'cell_type' + elif color_kind == 'continuous': + color_var = 'expression_level' + plot = create_manifoldmap_plot( + sadata.obsm['X_umap'], + sadata.obs[color_var].values, + 0, + 1, + color_var, + 'UMAP1', + 'UMAP2', + datashading=True, + ) + + if color_kind == 'categorical': + legend = plot.callback.inputs[0].callback.inputs[1] + assert legend.keys() == ['A', 'B'] + assert all(legend[color].label == color for color in ['A', 'B']) + assert legend['A'].opts.get('style').kwargs['color'] == cc.b_glasbey_category10[0] + assert legend['B'].opts.get('style').kwargs['color'] == cc.b_glasbey_category10[1] + + dm = plot.callback.inputs[0].callback.inputs[0] + rop = dm.callback.inputs[0].callback.inputs[0].callback.operation + assert rop.name == 'rasterize' + assert rop.p.aggregator.cat_column == color_var + dop = dm.callback.inputs[0].callback.operation + assert dop.name == 'dynspread' + assert dop.p.threshold == 0.5 + elif color_kind == 'continuous': + dm = plot.callback.inputs[0].callback.inputs[0] + rop = dm.callback.inputs[0].callback.operation + assert rop.name == 'rasterize' + assert rop.p.aggregator.__class__.__name__ == 'mean' + assert rop.p.aggregator.column == color_var + dop = dm.callback.operation + assert dop.name == 'dynspread' + assert dop.p.threshold == 0.5 + + +@pytest.mark.usefixtures("bokeh_backend") +def test_manifoldmap_initialization(sadata): + mm = ManifoldMap(adata=sadata) + + assert mm.dr_options == ["X_pca", "X_umap"] + assert mm.color_options == ["cell_type", "expression_level"] + assert mm.reduction == "X_pca" + assert mm.color_by == "cell_type" + + +@pytest.mark.usefixtures("bokeh_backend") +def test_manifoldmap_get_dim_labels(sadata): + mm = ManifoldMap(adata=sadata) + + assert mm.get_dim_labels('X_umap') == ['UMAP1', 'UMAP2'] + assert mm.get_dim_labels('X_pca') == ['PCA1', 'PCA2'] + + +@pytest.mark.usefixtures("bokeh_backend") +@patch('hv_anndata.manifoldmap.create_manifoldmap_plot') +def test_manifoldmap_create_plot(mock_cmp, sadata): + mm = ManifoldMap(adata=sadata) + + mm.create_plot( + dr_key="X_pca", + x_value="PCA1", + y_value="PCA2", + color_value="cell_type", + datashade_value=False, + label_value=True, + ) + mock_cmp.assert_called_once_with( + sadata.obsm['X_pca'], + sadata.obs['cell_type'].values, + 0, + 1, + 'cell_type', + 'PCA1', + 'PCA2', + width=300, + height=300, + datashading=False, + labels=True, + title='PCA.cell_type', + ) + + +@pytest.mark.usefixtures("bokeh_backend") +def test_manifoldmap_panel_layout(sadata): + mm = ManifoldMap(adata=sadata) + + layout = mm.__panel__() + + assert isinstance(layout, pn.layout.Row) + assert len(layout) == 2 From 409ba597aad0423d46b646950aca37e06594f385 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 2 May 2025 17:47:55 +0200 Subject: [PATCH 23/55] fix linting --- pyproject.toml | 13 ++-- tests/test_manifold.py | 154 +++++++++++++++++++++++------------------ 2 files changed, 92 insertions(+), 75 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4e7f518..5045ba5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,12 +64,13 @@ lint.per-file-ignores."src/hv_anndata/__main__.py" = [ "T201", # print is fine ] lint.per-file-ignores."tests/*" = [ - "D102", # Missing docstring in public method - "D103", # Missing docstring in public function - "D105", # Missing docstring in magic method - "INP001", # __init__.py - "RUF018", # assert with := is fine - "S101", # Use of assert + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D105", # Missing docstring in magic method + "INP001", # __init__.py + "PLR2004", # magic-value-comparison + "RUF018", # assert with := is fine + "S101", # Use of assert ] lint.allowed-confusables = [ "×", "’" ] lint.flake8-copyright.notice-rgx = "SPDX-License-Identifier: MPL-2\\.0" diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 21fe7c7..1bba061 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -1,4 +1,8 @@ -from unittest.mock import patch +"""Manifold module tests.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch import anndata as ad import colorcet as cc @@ -11,114 +15,126 @@ @pytest.fixture -def sadata(): +def sadata() -> ad.AnnData: n_obs = 10 n_vars = 5 n_dims = 2 - X = np.random.rand(n_obs, n_vars) + rng = np.random.default_rng() + + x = rng.random((n_obs, n_vars)) obsm = { - "X_pca": np.random.rand(n_obs, n_dims), - "X_umap": np.random.rand(n_obs, n_dims), + "X_pca": rng.random((n_obs, n_dims)), + "X_umap": rng.random((n_obs, n_dims)), } obs = pd.DataFrame( { "cell_type": ["A", "B"] * (n_obs // 2), - "expression_level": np.random.rand(n_obs), + "expression_level": rng.random((n_obs,)), }, index=[f"cell_{i}" for i in range(n_obs)], ) var = pd.DataFrame( index=[f"gene_{i}" for i in range(n_vars)], ) - return ad.AnnData(X=X, obs=obs, obsm=obsm, var=var) + return ad.AnnData(X=x, obs=obs, obsm=obsm, var=var) @pytest.mark.usefixtures("bokeh_backend") -@pytest.mark.parametrize('color_kind', ['categorical', 'continuous']) -def test_create_manifoldmap_plot_no_datashading(sadata, color_kind): - if color_kind == 'categorical': - color_var = 'cell_type' - elif color_kind == 'continuous': - color_var = 'expression_level' +@pytest.mark.parametrize("color_kind", ["categorical", "continuous"]) +def test_create_manifoldmap_plot_no_datashading( + sadata: ad.AnnData, color_kind: str +) -> None: + if color_kind == "categorical": + color_var = "cell_type" + elif color_kind == "continuous": + color_var = "expression_level" plot = create_manifoldmap_plot( - sadata.obsm['X_umap'], + sadata.obsm["X_umap"], sadata.obs[color_var].values, 0, 1, color_var, - 'UMAP1', - 'UMAP2', + "UMAP1", + "UMAP2", datashading=False, ) - assert plot.kdims == ['UMAP1', 'UMAP2'] + assert plot.kdims == ["UMAP1", "UMAP2"] assert plot.vdims == [color_var] - plot_opts = plot.opts.get('plot').kwargs - style_opts = plot.opts.get('style').kwargs - assert style_opts['color'] == color_var - assert style_opts['size'] == 1 - assert style_opts['alpha'] == 0.5 - assert plot_opts['padding'] == 0 - assert plot_opts['tools'] == ['hover', 'box_select', 'lasso_select'] - assert plot_opts['legend_position'] == 'right' - assert plot_opts['frame_width'] == 300 - assert plot_opts['frame_height'] == 300 - - if color_kind == 'categorical': - assert style_opts['cmap'] == cc.b_glasbey_category10[:2] == ['#1f77b3', '#ff7e0e'] - assert plot_opts['show_legend'] is True - assert plot_opts['colorbar'] is False - elif color_kind == 'continuous': - assert style_opts['cmap'] == 'viridis' - assert plot_opts['show_legend'] is False - assert plot_opts['colorbar'] is True + plot_opts = plot.opts.get("plot").kwargs + style_opts = plot.opts.get("style").kwargs + assert style_opts["color"] == color_var + assert style_opts["size"] == 1 + assert style_opts["alpha"] == 0.5 + assert plot_opts["padding"] == 0 + assert plot_opts["tools"] == ["hover", "box_select", "lasso_select"] + assert plot_opts["legend_position"] == "right" + assert plot_opts["frame_width"] == 300 + assert plot_opts["frame_height"] == 300 + + if color_kind == "categorical": + assert ( + style_opts["cmap"] == cc.b_glasbey_category10[:2] == ["#1f77b3", "#ff7e0e"] + ) + assert plot_opts["show_legend"] is True + assert plot_opts["colorbar"] is False + elif color_kind == "continuous": + assert style_opts["cmap"] == "viridis" + assert plot_opts["show_legend"] is False + assert plot_opts["colorbar"] is True @pytest.mark.usefixtures("bokeh_backend") -@pytest.mark.parametrize('color_kind', ['categorical', 'continuous']) -def test_create_manifoldmap_plot_datashading(sadata, color_kind): - if color_kind == 'categorical': - color_var = 'cell_type' - elif color_kind == 'continuous': - color_var = 'expression_level' +@pytest.mark.parametrize("color_kind", ["categorical", "continuous"]) +def test_create_manifoldmap_plot_datashading( + sadata: ad.AnnData, color_kind: str +) -> None: + if color_kind == "categorical": + color_var = "cell_type" + elif color_kind == "continuous": + color_var = "expression_level" plot = create_manifoldmap_plot( - sadata.obsm['X_umap'], + sadata.obsm["X_umap"], sadata.obs[color_var].values, 0, 1, color_var, - 'UMAP1', - 'UMAP2', + "UMAP1", + "UMAP2", datashading=True, ) - if color_kind == 'categorical': + if color_kind == "categorical": legend = plot.callback.inputs[0].callback.inputs[1] - assert legend.keys() == ['A', 'B'] - assert all(legend[color].label == color for color in ['A', 'B']) - assert legend['A'].opts.get('style').kwargs['color'] == cc.b_glasbey_category10[0] - assert legend['B'].opts.get('style').kwargs['color'] == cc.b_glasbey_category10[1] + assert legend.keys() == ["A", "B"] + assert all(legend[color].label == color for color in ["A", "B"]) + assert ( + legend["A"].opts.get("style").kwargs["color"] == cc.b_glasbey_category10[0] + ) + assert ( + legend["B"].opts.get("style").kwargs["color"] == cc.b_glasbey_category10[1] + ) dm = plot.callback.inputs[0].callback.inputs[0] rop = dm.callback.inputs[0].callback.inputs[0].callback.operation - assert rop.name == 'rasterize' + assert rop.name == "rasterize" assert rop.p.aggregator.cat_column == color_var dop = dm.callback.inputs[0].callback.operation - assert dop.name == 'dynspread' + assert dop.name == "dynspread" assert dop.p.threshold == 0.5 - elif color_kind == 'continuous': + elif color_kind == "continuous": dm = plot.callback.inputs[0].callback.inputs[0] rop = dm.callback.inputs[0].callback.operation - assert rop.name == 'rasterize' - assert rop.p.aggregator.__class__.__name__ == 'mean' + assert rop.name == "rasterize" + assert rop.p.aggregator.__class__.__name__ == "mean" assert rop.p.aggregator.column == color_var dop = dm.callback.operation - assert dop.name == 'dynspread' + assert dop.name == "dynspread" assert dop.p.threshold == 0.5 @pytest.mark.usefixtures("bokeh_backend") -def test_manifoldmap_initialization(sadata): +def test_manifoldmap_initialization(sadata: ad.AnnData) -> None: mm = ManifoldMap(adata=sadata) assert mm.dr_options == ["X_pca", "X_umap"] @@ -128,16 +144,16 @@ def test_manifoldmap_initialization(sadata): @pytest.mark.usefixtures("bokeh_backend") -def test_manifoldmap_get_dim_labels(sadata): +def test_manifoldmap_get_dim_labels(sadata: ad.AnnData) -> None: mm = ManifoldMap(adata=sadata) - assert mm.get_dim_labels('X_umap') == ['UMAP1', 'UMAP2'] - assert mm.get_dim_labels('X_pca') == ['PCA1', 'PCA2'] + assert mm.get_dim_labels("X_umap") == ["UMAP1", "UMAP2"] + assert mm.get_dim_labels("X_pca") == ["PCA1", "PCA2"] @pytest.mark.usefixtures("bokeh_backend") -@patch('hv_anndata.manifoldmap.create_manifoldmap_plot') -def test_manifoldmap_create_plot(mock_cmp, sadata): +@patch("hv_anndata.manifoldmap.create_manifoldmap_plot") +def test_manifoldmap_create_plot(mock_cmp: Mock, sadata: ad.AnnData) -> None: mm = ManifoldMap(adata=sadata) mm.create_plot( @@ -149,23 +165,23 @@ def test_manifoldmap_create_plot(mock_cmp, sadata): label_value=True, ) mock_cmp.assert_called_once_with( - sadata.obsm['X_pca'], - sadata.obs['cell_type'].values, + sadata.obsm["X_pca"], + sadata.obs["cell_type"].values, 0, 1, - 'cell_type', - 'PCA1', - 'PCA2', + "cell_type", + "PCA1", + "PCA2", width=300, height=300, datashading=False, labels=True, - title='PCA.cell_type', + title="PCA.cell_type", ) @pytest.mark.usefixtures("bokeh_backend") -def test_manifoldmap_panel_layout(sadata): +def test_manifoldmap_panel_layout(sadata: ad.AnnData) -> None: mm = ManifoldMap(adata=sadata) layout = mm.__panel__() From e59058e9c1cccd1512f2bfc6cac6a581cabfc829 Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 7 May 2025 15:24:38 +0200 Subject: [PATCH 24/55] use autocomplete input widget for color_by --- src/hv_anndata/manifoldmap.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 8b74425..37d5e5f 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -506,8 +506,15 @@ def __panel__(self) -> pn.viewable.Viewable: y_axis = pn.widgets.Select( name="Y-axis", options=initial_dims, value=initial_dims[1] ) - color = pn.widgets.Select.from_param( - self.param.color_by, options=self.color_options + color = pn.widgets.AutocompleteInput.from_param( + self.param.color_by, + options=self.color_options, + min_characters=0, + search_strategy="includes", + case_sensitive=False, + stylesheets=[ + ":host .bk-menu.bk-below {max-height: 200px; overflow-y: auto}" + ], ) datashade_switch = pn.widgets.Checkbox.from_param( self.param.datashade, name="Datashader Rasterize For Large Datasets" From 7fd8b0156ae77d91193cf1918ddb7aae4ea747d9 Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 7 May 2025 15:27:49 +0200 Subject: [PATCH 25/55] cell_type as a global var --- src/hv_anndata/manifoldmap.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 37d5e5f..2786b4e 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -19,6 +19,9 @@ from collections.abc import Sequence +DEFAULT_COLOR_BY = "cell_type" + + class ManifoldMapConfig(TypedDict, total=False): """Configuration options for manifold map plotting.""" @@ -364,8 +367,8 @@ def __init__(self, **params: object) -> None: self.color_options = list(self.adata.obs.columns) if not self.color_by: self.color_by = ( - "cell_type" - if "cell_type" in self.color_options + DEFAULT_COLOR_BY + if DEFAULT_COLOR_BY in self.color_options else self.color_options[0] ) From 79e59672af16cfe766a8419701e9a1800edeafec Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 9 May 2025 12:10:46 +0200 Subject: [PATCH 26/55] color by obs vs cols --- pyproject.toml | 1 + src/hv_anndata/manifoldmap.py | 88 ++++++++++++++++++++++------------- 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5045ba5..ffa5212 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ lint.ignore = [ "S603", # We don’t want to use `subprocess.run(shell=True)` "S607", # We don’t run commands with untrusted input "TD002", # No need to assign TODOs to some person + "TRY", ] lint.per-file-ignores."**/*.ipynb" = [ "I002", # Missing `from __future__ import annotations` is fine diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 2786b4e..c127f8c 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -2,8 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypedDict, Unpack -from warnings import warn +from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack import anndata as ad import colorcet as cc @@ -346,8 +345,12 @@ class ManifoldMap(pn.viewable.Viewer): reduction: str | None = param.String( # type: ignore[assignment] default=None, doc="Dimension reduction method", allow_None=True ) - color_by: str | None = param.String( # type: ignore[assignment] - default=None, doc="Coloring variable", allow_None=True + color_by_dim: str = param.Selector( # type: ignore[assignment] + default="obs", + objects={"Observations": "obs", "Genes": "cols"}, + ) + color_by: str = param.Selector( # type: ignore[assignment] + doc="Coloring variable" ) datashade: bool = param.Boolean(default=True, doc="Whether to enable datashading") # type: ignore[assignment] width: int = param.Integer(default=300, doc="Width of the plot") # type: ignore[assignment] @@ -356,6 +359,7 @@ class ManifoldMap(pn.viewable.Viewer): show_widgets: bool = param.Boolean( # type: ignore[assignment] default=True, doc="Whether to show control widgets" ) + _color_info: tuple = param.Tuple(length=2) # type: ignore[assignment] def __init__(self, **params: object) -> None: """Initialize the ManifoldMapApp with the given parameters.""" @@ -364,13 +368,35 @@ def __init__(self, **params: object) -> None: if not self.reduction: self.reduction = self.dr_options[0] - self.color_options = list(self.adata.obs.columns) + self._color_options = { + "obs": list(self.adata.obs.columns), + "cols": list(self.adata.var_names), + } + copts = self._color_options[self.color_by_dim] + self.param.color_by.objects = copts if not self.color_by: - self.color_by = ( - DEFAULT_COLOR_BY - if DEFAULT_COLOR_BY in self.color_options - else self.color_options[0] - ) + if ( + self.color_by_dim == "obs" + and DEFAULT_COLOR_BY in self._color_options["obs"] + ): + self.color_by = DEFAULT_COLOR_BY + else: + self.color_by = self._color_options[self.color_by_dim][0] + elif self.color_by not in copts: + msg = f"color_by variable {self.color_by!r} not found." + raise ValueError(msg) + else: + self._update_color_info() + + @param.depends("color_by_dim", watch=True) + def _on_color_by_dim(self) -> None: + values = self._color_options[self.color_by_dim] + self.param.color_by.objects = values + self.color_by = values[0] + + @param.depends("color_by", watch=True) + def _update_color_info(self) -> None: + self._color_info = (self.color_by_dim, self.color_by) def get_reduction_label(self, dr_key: str) -> str: """Get a display label for a dimension reduction key. @@ -410,7 +436,7 @@ def create_plot( dr_key: str, x_value: str, y_value: str, - color_value: str, + color_info: tuple[Literal["obs", "cols"], str], datashade_value: bool, label_value: bool, ) -> pn.viewable.Viewable: @@ -424,8 +450,8 @@ def create_plot( X-axis dimension label y_value Y-axis dimension label - color_value - Variable to use for coloring + color_info + Dimension and variable to use for coloring datashade_value Whether to enable datashading label_value @@ -454,21 +480,14 @@ def create_plot( f"Make sure to select valid {dr_label} dimensions." ) - # Get color data from .obs or X cols - try: - color_data = self.adata.obs[color_value].values - except KeyError: - try: - color_data = ( - self.adata.X.getcol(self.adata.var_names.get_loc(color_value)) - .toarray() - .flatten() - ) - except (KeyError, ValueError): - color_data = np.zeros(self.adata.n_obs) - warn( - f"Warning: Could not find {color_value} in obs or var", stacklevel=2 - ) + color_dim, color_key = color_info + if color_dim == "obs": + color_data = self.adata.obs[color_key].values + elif color_dim == "cols": + color_data = self.adata.obs_vector(color_key) + else: + msg = "color_dim must be obs or cols" + raise ValueError(msg) # Configure the plot config = ManifoldMapConfig( @@ -476,7 +495,7 @@ def create_plot( height=self.height, datashading=datashade_value, labels=label_value, - title=f"{dr_label}.{color_value}", + title=f"{dr_label}.{color_key}", ) return create_manifoldmap_plot( @@ -484,7 +503,7 @@ def create_plot( color_data, x_dim, y_dim, - color_value, + color_key, x_value, y_value, **config, @@ -509,9 +528,12 @@ def __panel__(self) -> pn.viewable.Viewable: y_axis = pn.widgets.Select( name="Y-axis", options=initial_dims, value=initial_dims[1] ) + color_dim = pn.widgets.RadioButtonGroup.from_param( + self.param.color_by_dim, + ) color = pn.widgets.AutocompleteInput.from_param( self.param.color_by, - options=self.color_options, + name="", min_characters=0, search_strategy="includes", case_sensitive=False, @@ -541,7 +563,7 @@ def reset_dimension_options(event: object) -> None: dr_key=dr_select, x_value=x_axis, y_value=y_axis, - color_value=color, + color_info=self.param["_color_info"], datashade_value=datashade_switch, label_value=label_switch, ) @@ -551,6 +573,8 @@ def reset_dimension_options(event: object) -> None: dr_select, x_axis, y_axis, + pn.pane.HTML("Color by"), + color_dim, color, datashade_switch, label_switch, From 6c752bca3a9a37a209032e8fb55dc69846665d22 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 9 May 2025 12:11:41 +0200 Subject: [PATCH 27/55] add tests --- pyproject.toml | 1 + tests/test_manifold.py | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffa5212..13f4dc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ lint.ignore = [ "ISC001", # Incompatible with formatter "S603", # We don’t want to use `subprocess.run(shell=True)` "S607", # We don’t run commands with untrusted input + "SLF", "TD002", # No need to assign TODOs to some person "TRY", ] diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 1bba061..8c8c6e6 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -134,13 +134,33 @@ def test_create_manifoldmap_plot_datashading( @pytest.mark.usefixtures("bokeh_backend") -def test_manifoldmap_initialization(sadata: ad.AnnData) -> None: +def test_manifoldmap_initialization_default(sadata: ad.AnnData) -> None: mm = ManifoldMap(adata=sadata) assert mm.dr_options == ["X_pca", "X_umap"] - assert mm.color_options == ["cell_type", "expression_level"] + assert mm.color_by_dim == "obs" assert mm.reduction == "X_pca" assert mm.color_by == "cell_type" + assert mm._color_options == { + "obs": ["cell_type", "expression_level"], + "cols": ["gene_0", "gene_1", "gene_2", "gene_3", "gene_4"], + } + assert mm._color_info == ("obs", "cell_type") + + +@pytest.mark.usefixtures("bokeh_backend") +def test_manifoldmap_initialization_color_by(sadata: ad.AnnData) -> None: + mm = ManifoldMap(adata=sadata, color_by_dim="cols", color_by="gene_1") + + assert mm.dr_options == ["X_pca", "X_umap"] + assert mm.color_by_dim == "cols" + assert mm.reduction == "X_pca" + assert mm.color_by == "gene_1" + assert mm._color_options == { + "obs": ["cell_type", "expression_level"], + "cols": ["gene_0", "gene_1", "gene_2", "gene_3", "gene_4"], + } + assert mm._color_info == ("cols", "gene_1") @pytest.mark.usefixtures("bokeh_backend") @@ -160,7 +180,7 @@ def test_manifoldmap_create_plot(mock_cmp: Mock, sadata: ad.AnnData) -> None: dr_key="X_pca", x_value="PCA1", y_value="PCA2", - color_value="cell_type", + color_info=("obs", "cell_type"), datashade_value=False, label_value=True, ) From e9a5c12c35d84f1333aef41923c30f17b672c29f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 9 May 2025 10:11:57 +0000 Subject: [PATCH 28/55] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 29866a4..e60ff4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ def _plotting_backend(backend: str) -> None: pytest.importorskip(backend) - if not hv.extension._loaded: # noqa: SLF001 + if not hv.extension._loaded: hv.extension(backend) hv.renderer(backend) curent_backend = hv.Store.current_backend From c6244e68a33f480ae23f1b72ed0cb9ca61d11149 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 9 May 2025 12:18:35 +0200 Subject: [PATCH 29/55] improve docstring --- src/hv_anndata/manifoldmap.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index c127f8c..67b6800 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -324,6 +324,8 @@ class ManifoldMap(pn.viewable.Viewer): AnnData object to visualize reduction Initial dimension reduction method to use + color_by_dim + Color by dimension, one of 'obs' (default) or 'cols. color_by Initial variable to use for coloring datashade From 05b36da2d668c0a0f34a36ae85561ff3d6e46317 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 9 May 2025 18:55:15 +0200 Subject: [PATCH 30/55] customize the colormap --- src/hv_anndata/manifoldmap.py | 88 +++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 67b6800..800d7f2 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack import anndata as ad +import bokeh +import bokeh.palettes import colorcet as cc import datashader as ds import holoviews as hv @@ -19,6 +21,26 @@ DEFAULT_COLOR_BY = "cell_type" +CAT_CMAPS = { + "Glasbey Cat10": cc.b_glasbey_category10, + "Cat20": bokeh.palettes.Category20_20, + "Glasbey cool": cc.glasbey_cool, +} +CONT_CMAPS = { + "Viridis": bokeh.palettes.Viridis256, + "Fire": cc.fire, + "Blues": cc.blues, +} +DEFAULT_CAT_CMAP = cc.b_glasbey_category10 +DEFAULT_CONT_CMAP = "viridis" + + +def _is_categorical(arr: np.ndarr) -> bool: + return ( + arr.dtype.name in ["category", "categorical", "bool"] + or np.issubdtype(arr.dtype, np.object_) + or np.issubdtype(arr.dtype, np.str_) + ) class ManifoldMapConfig(TypedDict, total=False): @@ -32,15 +54,13 @@ class ManifoldMapConfig(TypedDict, total=False): """whether to apply datashader (default: True)""" labels: bool """whether to overlay labels at median positions (default: False)""" - cont_cmap: str - """colormap for continuous data (default: "viridis")""" - cat_cmap: list - """colormap for categorical data (default: cc.b_glasbey_category10)""" + cmap: str | list[str] + """colormap""" title: str """plot title (default: "")""" -def create_manifoldmap_plot( +def create_manifoldmap_plot( # noqa: PLR0913 x_data: np.ndarray, color_data: np.ndarray, x_dim: int, @@ -48,6 +68,7 @@ def create_manifoldmap_plot( color_var: str, xaxis_label: str, yaxis_label: str, + categorical: bool | None = None, **config: Unpack[ManifoldMapConfig], ) -> hv.Element: """Create a comprehensive manifold map plot with options for datashading and labels. @@ -68,6 +89,8 @@ def create_manifoldmap_plot( Label for the x axis yaxis_label Label for the y axis + categorical: bool or None, default=None + Whether the data in color_var is categorical **config Additional configuration options including, see :class:`ManifoldMapConfig`. @@ -81,26 +104,25 @@ def create_manifoldmap_plot( height = config.get("height", 300) datashading = config.get("datashading", True) labels = config.get("labels", False) - cont_cmap = config.get("cont_cmap", "viridis") - cat_cmap = config.get("cat_cmap", cc.b_glasbey_category10) + cmap = config.get("cmap") title = config.get("title", "") # Determine if color data is categorical - is_categorical = ( - color_data.dtype.name in ["category", "categorical", "bool"] - or np.issubdtype(color_data.dtype, np.object_) - or np.issubdtype(color_data.dtype, np.str_) - ) + if categorical is None: + categorical = _is_categorical(color_data) # Set colormap and plot options based on data type - if is_categorical: + if categorical: n_unq_cat = len(np.unique(color_data)) + if cmap is None: + cmap = DEFAULT_CAT_CMAP # Use subset of categorical colormap to preserve distinct colors - cmap = cat_cmap[:n_unq_cat] + cmap = cmap[:n_unq_cat] colorbar = False show_legend = not labels else: - cmap = cont_cmap + if cmap is None: + cmap = DEFAULT_CONT_CMAP show_legend = False colorbar = True @@ -133,7 +155,7 @@ def create_manifoldmap_plot( plot = plot.opts(**plot_opts) # Add labels if categorical and requested - if is_categorical and labels: + if categorical and labels: plot = _add_category_labels( plot, x_data, @@ -145,7 +167,7 @@ def create_manifoldmap_plot( label_opts, ) # Apply datashading with different approaches for categorical vs continuous - elif is_categorical: + elif categorical: plot = _apply_categorical_datashading( plot, x_data=x_data, @@ -328,6 +350,8 @@ class ManifoldMap(pn.viewable.Viewer): Color by dimension, one of 'obs' (default) or 'cols. color_by Initial variable to use for coloring + colormap + Initial colormap to use for coloring datashade Whether to enable datashading width @@ -354,6 +378,7 @@ class ManifoldMap(pn.viewable.Viewer): color_by: str = param.Selector( # type: ignore[assignment] doc="Coloring variable" ) + colormap: str = param.Selector() datashade: bool = param.Boolean(default=True, doc="Whether to enable datashading") # type: ignore[assignment] width: int = param.Integer(default=300, doc="Width of the plot") # type: ignore[assignment] height: int = param.Integer(default=300, doc="Height of the plot") # type: ignore[assignment] @@ -362,6 +387,7 @@ class ManifoldMap(pn.viewable.Viewer): default=True, doc="Whether to show control widgets" ) _color_info: tuple = param.Tuple(length=2) # type: ignore[assignment] + _categorical: bool = param.Boolean() # type: ignore[assignment] def __init__(self, **params: object) -> None: """Initialize the ManifoldMapApp with the given parameters.""" @@ -388,7 +414,7 @@ def __init__(self, **params: object) -> None: msg = f"color_by variable {self.color_by!r} not found." raise ValueError(msg) else: - self._update_color_info() + self._update_on_color_by() @param.depends("color_by_dim", watch=True) def _on_color_by_dim(self) -> None: @@ -397,8 +423,20 @@ def _on_color_by_dim(self) -> None: self.color_by = values[0] @param.depends("color_by", watch=True) - def _update_color_info(self) -> None: - self._color_info = (self.color_by_dim, self.color_by) + def _update_on_color_by(self) -> None: + new_vals = {} + old_categorical = self._categorical + if self.color_by_dim == "obs": + color_data = self.adata.obs[self.color_by].values + elif self.color_by_dim == "cols": + color_data = self.adata.obs_vector(self.color_by) + new_vals["_categorical"] = new_categorical = _is_categorical(color_data) + if old_categorical != new_categorical: + cmaps = CAT_CMAPS if new_categorical else CONT_CMAPS + self.param.colormap.objects = cmaps + new_vals["colormap"] = next(iter(cmaps.values())) + new_vals["_color_info"] = (self.color_by_dim, self.color_by) + self.param.update(new_vals) def get_reduction_label(self, dr_key: str) -> str: """Get a display label for a dimension reduction key. @@ -441,6 +479,7 @@ def create_plot( color_info: tuple[Literal["obs", "cols"], str], datashade_value: bool, label_value: bool, + cmap: list[str] | str, ) -> pn.viewable.Viewable: """Create a manifold map plot with the specified parameters. @@ -458,6 +497,8 @@ def create_plot( Whether to enable datashading label_value Whether to show labels + cmap + Colormap Returns ------- @@ -498,6 +539,7 @@ def create_plot( datashading=datashade_value, labels=label_value, title=f"{dr_label}.{color_key}", + cmap=cmap, ) return create_manifoldmap_plot( @@ -508,6 +550,7 @@ def create_plot( color_key, x_value, y_value, + categorical=self._categorical, **config, ) @@ -543,6 +586,9 @@ def __panel__(self) -> pn.viewable.Viewable: ":host .bk-menu.bk-below {max-height: 200px; overflow-y: auto}" ], ) + colormap = pn.widgets.ColorMap.from_param( + self.param.colormap, + ) datashade_switch = pn.widgets.Checkbox.from_param( self.param.datashade, name="Datashader Rasterize For Large Datasets" ) @@ -568,6 +614,7 @@ def reset_dimension_options(event: object) -> None: color_info=self.param["_color_info"], datashade_value=datashade_switch, label_value=label_switch, + cmap=colormap, ) # Create widget box @@ -578,6 +625,7 @@ def reset_dimension_options(event: object) -> None: pn.pane.HTML("Color by"), color_dim, color, + colormap, datashade_switch, label_switch, visible=self.show_widgets, From 5b62ffc6cfc321ac7373ed80c4afc9f4686d6c9d Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 9 May 2025 18:55:20 +0200 Subject: [PATCH 31/55] add tests --- tests/test_manifold.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 8c8c6e6..498f040 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -183,6 +183,7 @@ def test_manifoldmap_create_plot(mock_cmp: Mock, sadata: ad.AnnData) -> None: color_info=("obs", "cell_type"), datashade_value=False, label_value=True, + cmap=["#1f77b3", "#ff7e0e"], ) mock_cmp.assert_called_once_with( sadata.obsm["X_pca"], @@ -192,11 +193,13 @@ def test_manifoldmap_create_plot(mock_cmp: Mock, sadata: ad.AnnData) -> None: "cell_type", "PCA1", "PCA2", + categorical=True, width=300, height=300, datashading=False, labels=True, title="PCA.cell_type", + cmap=["#1f77b3", "#ff7e0e"], ) From 48c4769eead8d23a8e757e7a5c5dc6e4c295feed Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 13 May 2025 18:13:55 +0200 Subject: [PATCH 32/55] rename Genes as Variables --- src/hv_anndata/manifoldmap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 800d7f2..2401b94 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -373,7 +373,7 @@ class ManifoldMap(pn.viewable.Viewer): ) color_by_dim: str = param.Selector( # type: ignore[assignment] default="obs", - objects={"Observations": "obs", "Genes": "cols"}, + objects={"Observations": "obs", "Variables": "cols"}, ) color_by: str = param.Selector( # type: ignore[assignment] doc="Coloring variable" From c43463ed6e68de12a3812ffa7cd72ba5027642ca Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 16 May 2025 14:07:22 +0200 Subject: [PATCH 33/55] integrate labeller operation --- pyproject.toml | 1 + src/hv_anndata/manifoldmap.py | 212 +++++++++++++--------------------- 2 files changed, 83 insertions(+), 130 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 13f4dc1..5bf845e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ lint.ignore = [ "D213", # Multi-line docstring summary should start at the first instead of second line "FIX002", # TODOs are fine "ISC001", # Incompatible with formatter + "N", # pep8-naming "S603", # We don’t want to use `subprocess.run(shell=True)` "S607", # We don’t run commands with untrusted input "SLF", diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 2401b94..90f063d 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack +from typing import TYPE_CHECKING, Literal, TypedDict, Unpack import anndata as ad import bokeh @@ -14,6 +14,7 @@ import numpy as np import panel as pn import param +from holoviews.operation import Operation from panel.reactive import hold if TYPE_CHECKING: @@ -43,6 +44,55 @@ def _is_categorical(arr: np.ndarr) -> bool: ) +class labeller(Operation): + """Add a Label element centered over categorical points.""" + + column = param.String() + + max_labels = param.Integer(10) + + min_count = param.Integer(default=100) + + streams = param.List([hv.streams.RangeXY]) + + x_range = param.Tuple( + default=None, + length=2, + doc=""" + The x_range as a tuple of min and max x-value. Auto-ranges + if set to None.""", + ) + + y_range = param.Tuple( + default=None, + length=2, + doc=""" + The x_range as a tuple of min and max x-value. Auto-ranges + if set to None.""", + ) + + def _process(self, el: hv.Dataset, key=None) -> hv.Labels: # noqa: ARG002, ANN001 + if self.p.x_range and self.p.y_range: + el = el[slice(*self.p.x_range), slice(*self.p.y_range)] + + df = el.dframe() # noqa: PD901 + xd, yd, cd = el.dimensions()[:3] + col = self.p.column or cd.name + result = ( + df.groupby(col) + .agg( + count=(col, "size"), # count of rows per group + x=(xd.name, "mean"), + y=(yd.name, "mean"), + ) + .query(f"count > {self.p.min_count}") + .sort_values("count", ascending=False) + .iloc[: self.p.max_labels] + .reset_index() + ) + return hv.Labels(result, ["x", "y"], col) + + class ManifoldMapConfig(TypedDict, total=False): """Configuration options for manifold map plotting.""" @@ -119,7 +169,7 @@ def create_manifoldmap_plot( # noqa: PLR0913 # Use subset of categorical colormap to preserve distinct colors cmap = cmap[:n_unq_cat] colorbar = False - show_legend = not labels + show_legend = True else: if cmap is None: cmap = DEFAULT_CONT_CMAP @@ -127,11 +177,12 @@ def create_manifoldmap_plot( # noqa: PLR0913 colorbar = True # Create basic plot - plot = hv.Points( + dataset = hv.Dataset( (x_data[:, x_dim], x_data[:, y_dim], color_data), [xaxis_label, yaxis_label], color_var, ) + plot = dataset.to(hv.Points) # Options for standard (non-datashaded) plot plot_opts = dict( @@ -146,40 +197,18 @@ def create_manifoldmap_plot( # noqa: PLR0913 legend_position="right", ) - # Options for labels - label_opts = dict(text_font_size="8pt", text_color="black") - # Apply different rendering based on configuration if not datashading: # Standard plot without datashading plot = plot.opts(**plot_opts) - # Add labels if categorical and requested - if categorical and labels: - plot = _add_category_labels( - plot, - x_data, - color_data, - x_dim, - y_dim, - xaxis_label, - yaxis_label, - label_opts, - ) # Apply datashading with different approaches for categorical vs continuous elif categorical: plot = _apply_categorical_datashading( plot, - x_data=x_data, color_data=color_data, - x_dim=x_dim, - y_dim=y_dim, color_var=color_var, cmap=cmap, - xaxis_label=xaxis_label, - yaxis_label=yaxis_label, - labels=labels, - label_opts=label_opts, ) else: # For continuous data, take the mean @@ -188,6 +217,11 @@ def create_manifoldmap_plot( # noqa: PLR0913 plot = hd.dynspread(plot, threshold=0.5) plot = plot.opts(cmap=cmap, colorbar=colorbar) + if categorical and labels: + # Options for labels + label_opts = dict(text_font_size="8pt", text_color="black") + plot = plot * labeller(dataset).opts(**label_opts) + # Apply final options to the plot return plot.opts( title=title, @@ -198,72 +232,12 @@ def create_manifoldmap_plot( # noqa: PLR0913 ) -def _add_category_labels( # noqa: PLR0913 - plot: hv.Element, - x_data: np.ndarray, - color_data: np.ndarray, - x_dim: int, - y_dim: int, - xaxis_label: str, - yaxis_label: str, - label_opts: dict[str, Any], -) -> hv.Element: - """Add category labels to a plot. - - Parameters - ---------- - plot - The base plot to add labels to - x_data - Coordinate data - color_data - Category data for coloring - x_dim - Index for x dimension - y_dim - Index for y dimension - xaxis_label - X-axis label - yaxis_label - Y-axis label - label_opts - Options for label formatting - - Returns - ------- - Plot with labels added - - """ - unique_categories = np.unique(color_data) - labels_data = [] - - for cat in unique_categories: - mask = color_data == cat - if np.any(mask): - median_x = np.median(x_data[mask, x_dim]) - median_y = np.median(x_data[mask, y_dim]) - labels_data.append((median_x, median_y, str(cat))) - - labels_element = hv.Labels(labels_data, [xaxis_label, yaxis_label], "Label").opts( - **label_opts - ) - - return plot * labels_element - - -def _apply_categorical_datashading( # noqa: PLR0913 +def _apply_categorical_datashading( plot: hv.Element, *, - x_data: np.ndarray, color_data: np.ndarray, - x_dim: int, - y_dim: int, color_var: str, cmap: Sequence[str], - xaxis_label: str, - yaxis_label: str, - labels: bool, - label_opts: dict[str, Any], ) -> hv.Element: """Apply datashading to categorical data. @@ -271,30 +245,16 @@ def _apply_categorical_datashading( # noqa: PLR0913 ---------- plot The base plot to apply datashading to - x_data - Coordinate data color_data Category data for coloring - x_dim - Index for x dimension - y_dim - Index for y dimension color_var Name of the color variable cmap Colormap to use - xaxis_label - X-axis label - yaxis_label - Y-axis label - labels - Whether to add category labels - label_opts - Options for label formatting Returns ------- - Datashaded plot with optional labels and legend + Datashaded plot with a custom legend """ # For categorical data, count by category @@ -303,35 +263,27 @@ def _apply_categorical_datashading( # noqa: PLR0913 plot = hd.dynspread(plot, threshold=0.5) plot = plot.opts(cmap=cmap, tools=["hover", "box_select", "lasso_select"]) - # Add either labels or a custom legend - if labels: - plot = _add_category_labels( - plot, x_data, color_data, x_dim, y_dim, xaxis_label, yaxis_label, label_opts - ) - else: - # Create a custom legend for datashaded categorical plot - unique_categories = np.unique(color_data) - color_key = dict( - zip(unique_categories, cmap[: len(unique_categories)], strict=False) - ) - legend_items = [ - hv.Points([0, 0], label=str(cat)).opts(color=color_key[cat], size=0) - for cat in unique_categories - ] - legend = hv.NdOverlay( - { - str(cat): item - for cat, item in zip(unique_categories, legend_items, strict=False) - } - ).opts( - show_legend=True, - legend_position="right", - legend_limit=100, - legend_cols=len(unique_categories) // 10 + 1, - ) - plot = plot * legend - - return plot + # Create a custom legend for datashaded categorical plot + unique_categories = np.unique(color_data) + color_key = dict( + zip(unique_categories, cmap[: len(unique_categories)], strict=False) + ) + legend_items = [ + hv.Points([0, 0], label=str(cat)).opts(color=color_key[cat], size=0) + for cat in unique_categories + ] + legend = hv.NdOverlay( + { + str(cat): item + for cat, item in zip(unique_categories, legend_items, strict=False) + } + ).opts( + show_legend=True, + legend_position="right", + legend_limit=100, + legend_cols=len(unique_categories) // 10 + 1, + ) + return plot * legend class ManifoldMap(pn.viewable.Viewer): From a98935bb856ba5fc4afd240d5b86db684d5a7865 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 16 May 2025 14:08:09 +0200 Subject: [PATCH 34/55] add tests --- tests/test_manifold.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 498f040..7d9e7be 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -6,12 +6,17 @@ import anndata as ad import colorcet as cc +import holoviews as hv import numpy as np import pandas as pd import panel as pn import pytest -from hv_anndata.manifoldmap import ManifoldMap, create_manifoldmap_plot +from hv_anndata.manifoldmap import ( + ManifoldMap, + create_manifoldmap_plot, + labeller, +) @pytest.fixture @@ -211,3 +216,28 @@ def test_manifoldmap_panel_layout(sadata: ad.AnnData) -> None: assert isinstance(layout, pn.layout.Row) assert len(layout) == 2 + + +def test_labeller() -> None: + df = pd.DataFrame( # noqa: PD901 + { + "UMAP1": [0, 1, 2, 3, 10], + "UMAP2": [0, 1, 2, 3, 10], + "cell_type": ["a", "a", "b", "b", "b"], + } + ) + dataset = hv.Dataset(df, kdims=["UMAP1", "UMAP2"], vdims=("cell_type")) + ldm = labeller(dataset, min_count=0) + labels = ldm[()] + expected_data = pd.DataFrame( + { + "cell_type": ["b", "a"], + "count": [3, 2], + "x": [5, 0.5], + "y": [5, 0.5], + } + ) + pd.testing.assert_frame_equal( + labels.data.sort_values("cell_type"), + expected_data.sort_values("cell_type"), + ) From f265b7246a6833eea2e1fcfc985e91fbacf9ae47 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 16 May 2025 14:11:36 +0200 Subject: [PATCH 35/55] prevent double plotting by using the Parameter as ref, not the widget --- src/hv_anndata/manifoldmap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 90f063d..08088f1 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -566,7 +566,7 @@ def reset_dimension_options(event: object) -> None: color_info=self.param["_color_info"], datashade_value=datashade_switch, label_value=label_switch, - cmap=colormap, + cmap=self.param["colormap"], ) # Create widget box From bf915347e877a803a67c13fd5a11988a8bab4c7e Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 21 May 2025 12:26:58 +0200 Subject: [PATCH 36/55] fix axes updates --- src/hv_anndata/manifoldmap.py | 85 +++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 08088f1..a75588e 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -320,9 +320,11 @@ class ManifoldMap(pn.viewable.Viewer): adata: ad.AnnData = param.ClassSelector( # type: ignore[assignment] class_=ad.AnnData, doc="AnnData object to visualize" ) - reduction: str | None = param.String( # type: ignore[assignment] - default=None, doc="Dimension reduction method", allow_None=True + reduction: str = param.Selector( # type: ignore[assignment] + doc="Dimension reduction method" ) + x_axis: str = param.Selector() # type: ignore[assignment] + y_axis: str = param.Selector() # type: ignore[assignment] color_by_dim: str = param.Selector( # type: ignore[assignment] default="obs", objects={"Observations": "obs", "Variables": "cols"}, @@ -344,9 +346,10 @@ class ManifoldMap(pn.viewable.Viewer): def __init__(self, **params: object) -> None: """Initialize the ManifoldMapApp with the given parameters.""" super().__init__(**params) - self.dr_options = list(self.adata.obsm.keys()) + dr_options = list(self.adata.obsm.keys()) + self.param["reduction"].objects = dr_options if not self.reduction: - self.reduction = self.dr_options[0] + self.reduction = dr_options[0] self._color_options = { "obs": list(self.adata.obs.columns), @@ -367,6 +370,7 @@ def __init__(self, **params: object) -> None: raise ValueError(msg) else: self._update_on_color_by() + self._update_axes() @param.depends("color_by_dim", watch=True) def _on_color_by_dim(self) -> None: @@ -390,6 +394,19 @@ def _update_on_color_by(self) -> None: new_vals["_color_info"] = (self.color_by_dim, self.color_by) self.param.update(new_vals) + @hold() + @param.depends("reduction", watch=True) + def _update_axes(self) -> None: + # Reset dimension options when reduction selection changes + new_dims = self.get_dim_labels(self.reduction) + vals = { + "x_axis": new_dims[0], + "y_axis": new_dims[1], + } + self.param.x_axis.objects = new_dims + self.param.y_axis.objects = new_dims + self.param.update(vals) + def get_reduction_label(self, dr_key: str) -> str: """Get a display label for a dimension reduction key. @@ -506,6 +523,27 @@ def create_plot( **config, ) + @param.depends( + # Only include derived parameters to avoid calling create_plot + # unnecessarily. + "x_axis", + "y_axis", + "_color_info", + "datashade", + "labels", + "colormap", + ) + def _plot_view(self) -> pn.viewable.Viewable: + return self.create_plot( + dr_key=self.reduction, + x_value=self.x_axis, + y_value=self.y_axis, + color_info=self._color_info, + datashade_value=self.datashade, + label_value=self.labels, + cmap=self.colormap, + ) + def __panel__(self) -> pn.viewable.Viewable: """Create the Panel application layout. @@ -515,16 +553,6 @@ def __panel__(self) -> pn.viewable.Viewable: """ # Widgets - dr_select = pn.widgets.Select.from_param( - self.param.reduction, options=self.dr_options - ) - initial_dims = self.get_dim_labels(dr_select.value) - x_axis = pn.widgets.Select( - name="X-axis", options=initial_dims, value=initial_dims[0] - ) - y_axis = pn.widgets.Select( - name="Y-axis", options=initial_dims, value=initial_dims[1] - ) color_dim = pn.widgets.RadioButtonGroup.from_param( self.param.color_by_dim, ) @@ -548,32 +576,11 @@ def __panel__(self) -> pn.viewable.Viewable: self.param.labels, name="Overlay Labels For Categorical Coloring" ) - # Reset dimension options when reduction selection changes - @hold() - def reset_dimension_options(event: object) -> None: - new_dims = self.get_dim_labels(event.new) - x_axis.param.update(options=new_dims, value=new_dims[0]) - y_axis.param.update(options=new_dims, value=new_dims[1]) - - dr_select.param.watch(reset_dimension_options, "value") - - # Bind the plot creation to widget values - plot_pane = pn.bind( - self.create_plot, - dr_key=dr_select, - x_value=x_axis, - y_value=y_axis, - color_info=self.param["_color_info"], - datashade_value=datashade_switch, - label_value=label_switch, - cmap=self.param["colormap"], - ) - # Create widget box widgets = pn.WidgetBox( - dr_select, - x_axis, - y_axis, + self.param.reduction, + self.param.x_axis, + self.param.y_axis, pn.pane.HTML("Color by"), color_dim, color, @@ -584,4 +591,4 @@ def reset_dimension_options(event: object) -> None: ) # Return the assembled layout - return pn.Row(widgets, plot_pane) + return pn.Row(widgets, self._plot_view) From e15d72b14d8a34e852a0ab6725d4933ccde445eb Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 21 May 2025 12:32:34 +0200 Subject: [PATCH 37/55] rename to show_labels --- src/hv_anndata/manifoldmap.py | 41 ++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index a75588e..e9e9f4d 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -102,7 +102,7 @@ class ManifoldMapConfig(TypedDict, total=False): """height of the plot (default: 300)""" datashading: bool """whether to apply datashader (default: True)""" - labels: bool + show_labels: bool """whether to overlay labels at median positions (default: False)""" cmap: str | list[str] """colormap""" @@ -153,7 +153,7 @@ def create_manifoldmap_plot( # noqa: PLR0913 width = config.get("width", 300) height = config.get("height", 300) datashading = config.get("datashading", True) - labels = config.get("labels", False) + show_labels = config.get("show_labels", False) cmap = config.get("cmap") title = config.get("title", "") @@ -217,7 +217,7 @@ def create_manifoldmap_plot( # noqa: PLR0913 plot = hd.dynspread(plot, threshold=0.5) plot = plot.opts(cmap=cmap, colorbar=colorbar) - if categorical and labels: + if categorical and show_labels: # Options for labels label_opts = dict(text_font_size="8pt", text_color="black") plot = plot * labeller(dataset).opts(**label_opts) @@ -310,7 +310,7 @@ class ManifoldMap(pn.viewable.Viewer): Width of the plot height Height of the plot - labels + show_labels Whether to show labels show_widgets Whether to show control widgets @@ -333,10 +333,18 @@ class ManifoldMap(pn.viewable.Viewer): doc="Coloring variable" ) colormap: str = param.Selector() - datashade: bool = param.Boolean(default=True, doc="Whether to enable datashading") # type: ignore[assignment] + datashade: bool = param.Boolean( # type: ignore[assignment] + default=True, + label="Datashader Rasterize For Large Datasets", + doc="Whether to enable datashading", + ) width: int = param.Integer(default=300, doc="Width of the plot") # type: ignore[assignment] height: int = param.Integer(default=300, doc="Height of the plot") # type: ignore[assignment] - labels: bool = param.Boolean(default=False, doc="Whether to show labels") # type: ignore[assignment] + show_labels: bool = param.Boolean( # type: ignore[assignment] + default=False, + label="Overlay Labels For Categorical Coloring", + doc="Whether to show labels", + ) show_widgets: bool = param.Boolean( # type: ignore[assignment] default=True, doc="Whether to show control widgets" ) @@ -447,7 +455,7 @@ def create_plot( y_value: str, color_info: tuple[Literal["obs", "cols"], str], datashade_value: bool, - label_value: bool, + show_labels: bool, cmap: list[str] | str, ) -> pn.viewable.Viewable: """Create a manifold map plot with the specified parameters. @@ -464,7 +472,7 @@ def create_plot( Dimension and variable to use for coloring datashade_value Whether to enable datashading - label_value + show_labels Whether to show labels cmap Colormap @@ -506,7 +514,7 @@ def create_plot( width=self.width, height=self.height, datashading=datashade_value, - labels=label_value, + show_labels=show_labels, title=f"{dr_label}.{color_key}", cmap=cmap, ) @@ -530,7 +538,7 @@ def create_plot( "y_axis", "_color_info", "datashade", - "labels", + "show_labels", "colormap", ) def _plot_view(self) -> pn.viewable.Viewable: @@ -540,7 +548,7 @@ def _plot_view(self) -> pn.viewable.Viewable: y_value=self.y_axis, color_info=self._color_info, datashade_value=self.datashade, - label_value=self.labels, + show_labels=self.show_labels, cmap=self.colormap, ) @@ -569,13 +577,6 @@ def __panel__(self) -> pn.viewable.Viewable: colormap = pn.widgets.ColorMap.from_param( self.param.colormap, ) - datashade_switch = pn.widgets.Checkbox.from_param( - self.param.datashade, name="Datashader Rasterize For Large Datasets" - ) - label_switch = pn.widgets.Checkbox.from_param( - self.param.labels, name="Overlay Labels For Categorical Coloring" - ) - # Create widget box widgets = pn.WidgetBox( self.param.reduction, @@ -585,8 +586,8 @@ def __panel__(self) -> pn.viewable.Viewable: color_dim, color, colormap, - datashade_switch, - label_switch, + self.param.datashade, + self.param.show_labels, visible=self.show_widgets, ) From 6fec455bb6de95d949bd3ee09d278823af9f0333 Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 21 May 2025 13:04:51 +0200 Subject: [PATCH 38/55] refactor internal color data message passing --- pyproject.toml | 23 ++++++------ src/hv_anndata/manifoldmap.py | 66 ++++++++++++++++++----------------- 2 files changed, 46 insertions(+), 43 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5bf845e..9fbba60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,18 +43,19 @@ extra-dependencies = [ [tool.ruff] lint.select = [ "ALL" ] lint.ignore = [ - "B019", # functools.cache is fine to use - "C408", # dict(...) calls are good - "COM812", # Incompatible with formatter - "D203", # 0 instead of 1 blank lines before class docstring - "D213", # Multi-line docstring summary should start at the first instead of second line - "FIX002", # TODOs are fine - "ISC001", # Incompatible with formatter - "N", # pep8-naming - "S603", # We don’t want to use `subprocess.run(shell=True)` - "S607", # We don’t run commands with untrusted input + "B019", # functools.cache is fine to use + "C408", # dict(...) calls are good + "COM812", # Incompatible with formatter + "D203", # 0 instead of 1 blank lines before class docstring + "D213", # Multi-line docstring summary should start at the first instead of second line + "FIX002", # TODOs are fine + "ISC001", # Incompatible with formatter + "N", # pep8-naming + "PLR0913", # Too many arguments in function definition + "S603", # We don’t want to use `subprocess.run(shell=True)` + "S607", # We don’t run commands with untrusted input "SLF", - "TD002", # No need to assign TODOs to some person + "TD002", # No need to assign TODOs to some person "TRY", ] lint.per-file-ignores."**/*.ipynb" = [ diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index e9e9f4d..7241e8f 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -110,12 +110,12 @@ class ManifoldMapConfig(TypedDict, total=False): """plot title (default: "")""" -def create_manifoldmap_plot( # noqa: PLR0913 +def create_manifoldmap_plot( x_data: np.ndarray, color_data: np.ndarray, x_dim: int, y_dim: int, - color_var: str, + color_by: str, xaxis_label: str, yaxis_label: str, categorical: bool | None = None, @@ -133,14 +133,14 @@ def create_manifoldmap_plot( # noqa: PLR0913 Index to use for x-axis data y_dim Index to use for y-axis data - color_var + color_by Name to give the coloring dimension xaxis_label Label for the x axis yaxis_label Label for the y axis categorical: bool or None, default=None - Whether the data in color_var is categorical + Whether the data in color_by is categorical **config Additional configuration options including, see :class:`ManifoldMapConfig`. @@ -180,13 +180,13 @@ def create_manifoldmap_plot( # noqa: PLR0913 dataset = hv.Dataset( (x_data[:, x_dim], x_data[:, y_dim], color_data), [xaxis_label, yaxis_label], - color_var, + color_by, ) plot = dataset.to(hv.Points) # Options for standard (non-datashaded) plot plot_opts = dict( - color=color_var, + color=color_by, cmap=cmap, size=1, alpha=0.5, @@ -207,12 +207,12 @@ def create_manifoldmap_plot( # noqa: PLR0913 plot = _apply_categorical_datashading( plot, color_data=color_data, - color_var=color_var, + color_by=color_by, cmap=cmap, ) else: # For continuous data, take the mean - aggregator = ds.mean(color_var) + aggregator = ds.mean(color_by) plot = hd.rasterize(plot, aggregator=aggregator) plot = hd.dynspread(plot, threshold=0.5) plot = plot.opts(cmap=cmap, colorbar=colorbar) @@ -236,7 +236,7 @@ def _apply_categorical_datashading( plot: hv.Element, *, color_data: np.ndarray, - color_var: str, + color_by: str, cmap: Sequence[str], ) -> hv.Element: """Apply datashading to categorical data. @@ -247,7 +247,7 @@ def _apply_categorical_datashading( The base plot to apply datashading to color_data Category data for coloring - color_var + color_by Name of the color variable cmap Colormap to use @@ -258,7 +258,7 @@ def _apply_categorical_datashading( """ # For categorical data, count by category - aggregator = ds.count_cat(color_var) + aggregator = ds.count_cat(color_by) plot = hd.rasterize(plot, aggregator=aggregator) plot = hd.dynspread(plot, threshold=0.5) plot = plot.opts(cmap=cmap, tools=["hover", "box_select", "lasso_select"]) @@ -348,12 +348,12 @@ class ManifoldMap(pn.viewable.Viewer): show_widgets: bool = param.Boolean( # type: ignore[assignment] default=True, doc="Whether to show control widgets" ) - _color_info: tuple = param.Tuple(length=2) # type: ignore[assignment] - _categorical: bool = param.Boolean() # type: ignore[assignment] + _replot: bool = param.Event() # type: ignore[assignment] def __init__(self, **params: object) -> None: """Initialize the ManifoldMapApp with the given parameters.""" super().__init__(**params) + self._categorical = False dr_options = list(self.adata.obsm.keys()) self.param["reduction"].objects = dr_options if not self.reduction: @@ -394,13 +394,13 @@ def _update_on_color_by(self) -> None: color_data = self.adata.obs[self.color_by].values elif self.color_by_dim == "cols": color_data = self.adata.obs_vector(self.color_by) - new_vals["_categorical"] = new_categorical = _is_categorical(color_data) + self._categorical = new_categorical = _is_categorical(color_data) if old_categorical != new_categorical: cmaps = CAT_CMAPS if new_categorical else CONT_CMAPS self.param.colormap.objects = cmaps - new_vals["colormap"] = next(iter(cmaps.values())) - new_vals["_color_info"] = (self.color_by_dim, self.color_by) + self.colormap = next(iter(cmaps.values())) self.param.update(new_vals) + self._replot = True @hold() @param.depends("reduction", watch=True) @@ -453,7 +453,8 @@ def create_plot( dr_key: str, x_value: str, y_value: str, - color_info: tuple[Literal["obs", "cols"], str], + color_by_dim: Literal["obs", "cols"], + color_by: str, datashade_value: bool, show_labels: bool, cmap: list[str] | str, @@ -468,8 +469,10 @@ def create_plot( X-axis dimension label y_value Y-axis dimension label - color_info - Dimension and variable to use for coloring + color_by_dim + Dimension to use for coloring + color_by + Variable to use for coloring datashade_value Whether to enable datashading show_labels @@ -500,13 +503,12 @@ def create_plot( f"Make sure to select valid {dr_label} dimensions." ) - color_dim, color_key = color_info - if color_dim == "obs": - color_data = self.adata.obs[color_key].values - elif color_dim == "cols": - color_data = self.adata.obs_vector(color_key) + if color_by_dim == "obs": + color_data = self.adata.obs[color_by].values + elif color_by_dim == "cols": + color_data = self.adata.obs_vector(color_by) else: - msg = "color_dim must be obs or cols" + msg = "color_by_dim must be obs or cols" raise ValueError(msg) # Configure the plot @@ -515,7 +517,7 @@ def create_plot( height=self.height, datashading=datashade_value, show_labels=show_labels, - title=f"{dr_label}.{color_key}", + title=f"{dr_label}.{color_by}", cmap=cmap, ) @@ -524,7 +526,7 @@ def create_plot( color_data, x_dim, y_dim, - color_key, + color_by, x_value, y_value, categorical=self._categorical, @@ -536,17 +538,17 @@ def create_plot( # unnecessarily. "x_axis", "y_axis", - "_color_info", "datashade", "show_labels", - "colormap", + "_replot", ) def _plot_view(self) -> pn.viewable.Viewable: return self.create_plot( dr_key=self.reduction, x_value=self.x_axis, y_value=self.y_axis, - color_info=self._color_info, + color_by_dim=self.color_by_dim, + color_by=self.color_by, datashade_value=self.datashade, show_labels=self.show_labels, cmap=self.colormap, @@ -561,7 +563,7 @@ def __panel__(self) -> pn.viewable.Viewable: """ # Widgets - color_dim = pn.widgets.RadioButtonGroup.from_param( + color_by_dim = pn.widgets.RadioButtonGroup.from_param( self.param.color_by_dim, ) color = pn.widgets.AutocompleteInput.from_param( @@ -583,7 +585,7 @@ def __panel__(self) -> pn.viewable.Viewable: self.param.x_axis, self.param.y_axis, pn.pane.HTML("Color by"), - color_dim, + color_by_dim, color, colormap, self.param.datashade, From 552b371e0b9af5bd2377fb47d8a157119d01f559 Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 21 May 2025 13:07:41 +0200 Subject: [PATCH 39/55] update the tests --- tests/test_manifold.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 7d9e7be..aa9c9ef 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -142,7 +142,7 @@ def test_create_manifoldmap_plot_datashading( def test_manifoldmap_initialization_default(sadata: ad.AnnData) -> None: mm = ManifoldMap(adata=sadata) - assert mm.dr_options == ["X_pca", "X_umap"] + assert mm.param.reduction.objects == ["X_pca", "X_umap"] assert mm.color_by_dim == "obs" assert mm.reduction == "X_pca" assert mm.color_by == "cell_type" @@ -150,14 +150,13 @@ def test_manifoldmap_initialization_default(sadata: ad.AnnData) -> None: "obs": ["cell_type", "expression_level"], "cols": ["gene_0", "gene_1", "gene_2", "gene_3", "gene_4"], } - assert mm._color_info == ("obs", "cell_type") @pytest.mark.usefixtures("bokeh_backend") def test_manifoldmap_initialization_color_by(sadata: ad.AnnData) -> None: mm = ManifoldMap(adata=sadata, color_by_dim="cols", color_by="gene_1") - assert mm.dr_options == ["X_pca", "X_umap"] + assert mm.param.reduction.objects == ["X_pca", "X_umap"] assert mm.color_by_dim == "cols" assert mm.reduction == "X_pca" assert mm.color_by == "gene_1" @@ -165,7 +164,6 @@ def test_manifoldmap_initialization_color_by(sadata: ad.AnnData) -> None: "obs": ["cell_type", "expression_level"], "cols": ["gene_0", "gene_1", "gene_2", "gene_3", "gene_4"], } - assert mm._color_info == ("cols", "gene_1") @pytest.mark.usefixtures("bokeh_backend") @@ -185,9 +183,10 @@ def test_manifoldmap_create_plot(mock_cmp: Mock, sadata: ad.AnnData) -> None: dr_key="X_pca", x_value="PCA1", y_value="PCA2", - color_info=("obs", "cell_type"), + color_by_dim="obs", + color_by="cell_type", datashade_value=False, - label_value=True, + show_labels=True, cmap=["#1f77b3", "#ff7e0e"], ) mock_cmp.assert_called_once_with( @@ -202,7 +201,7 @@ def test_manifoldmap_create_plot(mock_cmp: Mock, sadata: ad.AnnData) -> None: width=300, height=300, datashading=False, - labels=True, + show_labels=True, title="PCA.cell_type", cmap=["#1f77b3", "#ff7e0e"], ) From 0cdd37b539c85c9cf180201ebc402dbb6a5e0b51 Mon Sep 17 00:00:00 2001 From: maximlt Date: Thu, 22 May 2025 14:53:33 +0200 Subject: [PATCH 40/55] add selector to get a better tooltip for datashaded categorical plots --- src/hv_anndata/manifoldmap.py | 16 +++++++++++++--- tests/test_manifold.py | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 7241e8f..96c0a7c 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -259,12 +259,22 @@ def _apply_categorical_datashading( """ # For categorical data, count by category aggregator = ds.count_cat(color_by) - plot = hd.rasterize(plot, aggregator=aggregator) + # Selector used as a workaround to display categorical counts per pixel + # One day done directly in Bokeh, see https://github.com/bokeh/bokeh/issues/13354 + selector = ds.first(plot.kdims[0].name) + plot = hd.rasterize(plot, aggregator=aggregator, selector=selector) plot = hd.dynspread(plot, threshold=0.5) - plot = plot.opts(cmap=cmap, tools=["hover", "box_select", "lasso_select"]) + unique_categories = np.unique(color_data) + plot = plot.opts( + cmap=cmap, + tools=["hover", "box_select", "lasso_select"], + # Override hover_tooltips to exclude the selector value + hover_tooltips=list(unique_categories), + # Don't include the selector heading + selector_in_hovertool=False, + ) # Create a custom legend for datashaded categorical plot - unique_categories = np.unique(color_data) color_key = dict( zip(unique_categories, cmap[: len(unique_categories)], strict=False) ) diff --git a/tests/test_manifold.py b/tests/test_manifold.py index aa9c9ef..afff23f 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -124,6 +124,7 @@ def test_create_manifoldmap_plot_datashading( rop = dm.callback.inputs[0].callback.inputs[0].callback.operation assert rop.name == "rasterize" assert rop.p.aggregator.cat_column == color_var + assert rop.p.selector.column == "UMAP1" dop = dm.callback.inputs[0].callback.operation assert dop.name == "dynspread" assert dop.p.threshold == 0.5 From 3f1098d569b8cbd01f4fd14fefff80a4313b18cc Mon Sep 17 00:00:00 2001 From: maximlt Date: Thu, 22 May 2025 15:59:22 +0200 Subject: [PATCH 41/55] Watch colormap --- src/hv_anndata/manifoldmap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 96c0a7c..73db186 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -548,6 +548,7 @@ def create_plot( # unnecessarily. "x_axis", "y_axis", + "colormap", "datashade", "show_labels", "_replot", From eee7843c5ff6ea9d2ae38f1059b763031ad537ed Mon Sep 17 00:00:00 2001 From: maximlt Date: Thu, 22 May 2025 16:02:59 +0200 Subject: [PATCH 42/55] display NaN category --- pyproject.toml | 1 + src/hv_anndata/manifoldmap.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9fbba60..f2aa96a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ lint.ignore = [ "FIX002", # TODOs are fine "ISC001", # Incompatible with formatter "N", # pep8-naming + "PLR0124", "PLR0913", # Too many arguments in function definition "S603", # We don’t want to use `subprocess.run(shell=True)` "S607", # We don’t run commands with untrusted input diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 73db186..1702ed1 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -161,6 +161,14 @@ def create_manifoldmap_plot( if categorical is None: categorical = _is_categorical(color_data) + # Add a NaN category to handle and display data points with no category + if categorical: + color_data = np.where( + color_data != color_data, + "NaN", + color_data, + ) # np.nan != np.nan is True + # Set colormap and plot options based on data type if categorical: n_unq_cat = len(np.unique(color_data)) From ecc2cbb94317fd07d322fc1faab29222d90ea19c Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 10 Jun 2025 17:38:22 +0200 Subject: [PATCH 43/55] cycle too short cat cmap --- src/hv_anndata/manifoldmap.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 1702ed1..c51da7d 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -283,6 +283,9 @@ def _apply_categorical_datashading( ) # Create a custom legend for datashaded categorical plot + if len(unique_categories) > len(cmap): + # cmap not long enough, cycle it + cmap = cmap * (len(unique_categories) // len(cmap) + 1) color_key = dict( zip(unique_categories, cmap[: len(unique_categories)], strict=False) ) From eca12b91bf45987a45b08e3d241c65ebd2ad4a49 Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 10 Jun 2025 17:39:57 +0200 Subject: [PATCH 44/55] set holoviews pin --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2aa96a..7e0961d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dynamic = [ "description", "version" ] -dependencies = [ "anndata", "datashader", "holoviews", "numpy", "panel" ] +dependencies = [ "anndata", "datashader", "holoviews>=1.21.0b3", "numpy", "panel" ] [tool.hatch.metadata.hooks.docstring-description] [tool.hatch.version] From ef9d837dc3dfdb4f4b58d71e5ea74110d8a3a171 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 13 Jun 2025 17:38:54 +0200 Subject: [PATCH 45/55] fix continuous colormap on init --- src/hv_anndata/manifoldmap.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index c51da7d..10141de 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -409,18 +409,16 @@ def _on_color_by_dim(self) -> None: @param.depends("color_by", watch=True) def _update_on_color_by(self) -> None: - new_vals = {} - old_categorical = self._categorical + old_is_categorical = self._categorical if self.color_by_dim == "obs": color_data = self.adata.obs[self.color_by].values elif self.color_by_dim == "cols": color_data = self.adata.obs_vector(self.color_by) - self._categorical = new_categorical = _is_categorical(color_data) - if old_categorical != new_categorical: - cmaps = CAT_CMAPS if new_categorical else CONT_CMAPS + self._categorical = _is_categorical(color_data) + if old_is_categorical != self._categorical or not self.colormap: + cmaps = CAT_CMAPS if self._categorical else CONT_CMAPS self.param.colormap.objects = cmaps self.colormap = next(iter(cmaps.values())) - self.param.update(new_vals) self._replot = True @hold() From f9a5689d0d6a2cf7c5ff1ee5f7a94592951dac79 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 13 Jun 2025 17:48:54 +0200 Subject: [PATCH 46/55] resample_when for the continous case --- pyproject.toml | 1 + src/hv_anndata/manifoldmap.py | 23 ++++++++++++++++++----- tests/test_manifold.py | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7e0961d..1e7d2f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ extra-dependencies = [ [tool.ruff] lint.select = [ "ALL" ] lint.ignore = [ + "ANN401", "B019", # functools.cache is fine to use "C408", # dict(...) calls are good "COM812", # Incompatible with formatter diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 10141de..2688d7b 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal, TypedDict, Unpack +from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack import anndata as ad import bokeh @@ -14,7 +14,7 @@ import numpy as np import panel as pn import param -from holoviews.operation import Operation +from holoviews.operation import Operation, apply_when from panel.reactive import hold if TYPE_CHECKING: @@ -34,6 +34,7 @@ } DEFAULT_CAT_CMAP = cc.b_glasbey_category10 DEFAULT_CONT_CMAP = "viridis" +APPLY_WHEN_THRESHOLD = 1000 def _is_categorical(arr: np.ndarr) -> bool: @@ -221,9 +222,21 @@ def create_manifoldmap_plot( else: # For continuous data, take the mean aggregator = ds.mean(color_by) - plot = hd.rasterize(plot, aggregator=aggregator) - plot = hd.dynspread(plot, threshold=0.5) - plot = plot.opts(cmap=cmap, colorbar=colorbar) + + def operation(obj: Any) -> Any: + obj = hd.rasterize(obj, aggregator=aggregator) + # Applying opts here instead of after apply_when to ensure + # they're applied to the right element (e.g. Points doesn't support cmap) + # Adding hover too as somehow it wouldn't be enabled despite + # the final options applied later. + obj = obj.opts(cmap=cmap, colorbar=colorbar, tools=["hover"]) + return hd.dynspread(obj, threshold=0.5) + + plot = apply_when( + plot, + operation=operation, + predicate=lambda obj: len(obj) > APPLY_WHEN_THRESHOLD, + ) if categorical and show_labels: # Options for labels diff --git a/tests/test_manifold.py b/tests/test_manifold.py index afff23f..f2a30ec 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -130,7 +130,7 @@ def test_create_manifoldmap_plot_datashading( assert dop.p.threshold == 0.5 elif color_kind == "continuous": dm = plot.callback.inputs[0].callback.inputs[0] - rop = dm.callback.inputs[0].callback.operation + rop = dm.callback.inputs[0].callback.inputs[0].callback.operation assert rop.name == "rasterize" assert rop.p.aggregator.__class__.__name__ == "mean" assert rop.p.aggregator.column == color_var From acab66fb0858394850e42170f066cb27e63c3b19 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 13 Jun 2025 18:50:21 +0200 Subject: [PATCH 47/55] support setting a var reference --- src/hv_anndata/manifoldmap.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 2688d7b..5060b5b 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -372,6 +372,14 @@ class ManifoldMap(pn.viewable.Viewer): label="Datashader Rasterize For Large Datasets", doc="Whether to enable datashading", ) + var_reference: str | None = param.String( # type: ignore[assignment] + default=None, + allow_None=True, + doc=""" + Column name in .var to use for populating the variable names, default + to the index names if not set. + """, + ) width: int = param.Integer(default=300, doc="Width of the plot") # type: ignore[assignment] height: int = param.Integer(default=300, doc="Height of the plot") # type: ignore[assignment] show_labels: bool = param.Boolean( # type: ignore[assignment] @@ -393,9 +401,13 @@ def __init__(self, **params: object) -> None: if not self.reduction: self.reduction = dr_options[0] + if self.var_reference: + cols = list(self.adata.var[self.var_reference]) + else: + cols = list(self.adata.var_names) self._color_options = { "obs": list(self.adata.obs.columns), - "cols": list(self.adata.var_names), + "cols": cols, } copts = self._color_options[self.color_by_dim] self.param.color_by.objects = copts @@ -426,7 +438,7 @@ def _update_on_color_by(self) -> None: if self.color_by_dim == "obs": color_data = self.adata.obs[self.color_by].values elif self.color_by_dim == "cols": - color_data = self.adata.obs_vector(self.color_by) + color_data = self.adata.obs_vector(self._get_var()) self._categorical = _is_categorical(color_data) if old_is_categorical != self._categorical or not self.colormap: cmaps = CAT_CMAPS if self._categorical else CONT_CMAPS @@ -447,6 +459,20 @@ def _update_axes(self) -> None: self.param.y_axis.objects = new_dims self.param.update(vals) + def _get_var(self) -> str: + if self.var_reference: + var = self.adata.var.query(f'feature_name == "{self.color_by}"').index + if len(var) > 1: + msg = ( + f"More than one vars found in {self.var_reference!r} " + f"for {self.color_by!r}." + ) + raise RuntimeError(msg) + var = var.item() + else: + var = self.color_by + return var + def get_reduction_label(self, dr_key: str) -> str: """Get a display label for a dimension reduction key. @@ -538,7 +564,7 @@ def create_plot( if color_by_dim == "obs": color_data = self.adata.obs[color_by].values elif color_by_dim == "cols": - color_data = self.adata.obs_vector(color_by) + color_data = self.adata.obs_vector(self._get_var()) else: msg = "color_by_dim must be obs or cols" raise ValueError(msg) From f2b4fc4812a8eb414232b92a55c71f416d336d40 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 20 Jun 2025 17:37:44 +0200 Subject: [PATCH 48/55] keep the tools for continuous --- src/hv_anndata/manifoldmap.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 5060b5b..c259231 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -226,12 +226,15 @@ def create_manifoldmap_plot( def operation(obj: Any) -> Any: obj = hd.rasterize(obj, aggregator=aggregator) # Applying opts here instead of after apply_when to ensure - # they're applied to the right element (e.g. Points doesn't support cmap) - # Adding hover too as somehow it wouldn't be enabled despite - # the final options applied later. - obj = obj.opts(cmap=cmap, colorbar=colorbar, tools=["hover"]) + # they're applied to the right element. + obj = obj.opts( + cmap=cmap, + colorbar=colorbar, + tools=["hover", "box_select", "lasso_select"], + ) return hd.dynspread(obj, threshold=0.5) + plot = plot.opts(tools=["hover", "box_select", "lasso_select"]) plot = apply_when( plot, operation=operation, From c138b28656667364048ac3fb5a19404db81addf5 Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 20 Jun 2025 18:17:12 +0200 Subject: [PATCH 49/55] persistent select tools --- src/hv_anndata/manifoldmap.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index c259231..473d5ad 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -14,6 +14,7 @@ import numpy as np import panel as pn import param +from bokeh.models.tools import BoxSelectTool, LassoSelectTool from holoviews.operation import Operation, apply_when from panel.reactive import hold @@ -201,7 +202,11 @@ def create_manifoldmap_plot( alpha=0.5, colorbar=colorbar, padding=0, - tools=["hover", "box_select", "lasso_select"], + tools=[ + "hover", + BoxSelectTool(persistent=True), + LassoSelectTool(persistent=True), + ], show_legend=show_legend, legend_position="right", ) @@ -230,11 +235,21 @@ def operation(obj: Any) -> Any: obj = obj.opts( cmap=cmap, colorbar=colorbar, - tools=["hover", "box_select", "lasso_select"], + tools=[ + "hover", + BoxSelectTool(persistent=True), + LassoSelectTool(persistent=True), + ], ) return hd.dynspread(obj, threshold=0.5) - plot = plot.opts(tools=["hover", "box_select", "lasso_select"]) + plot = plot.opts( + tools=[ + "hover", + BoxSelectTool(persistent=True), + LassoSelectTool(persistent=True), + ] + ) plot = apply_when( plot, operation=operation, @@ -249,7 +264,6 @@ def operation(obj: Any) -> Any: # Apply final options to the plot return plot.opts( title=title, - tools=["hover", "box_select", "lasso_select"], show_legend=show_legend, frame_width=width, frame_height=height, @@ -291,7 +305,11 @@ def _apply_categorical_datashading( unique_categories = np.unique(color_data) plot = plot.opts( cmap=cmap, - tools=["hover", "box_select", "lasso_select"], + tools=[ + "hover", + BoxSelectTool(persistent=True), + LassoSelectTool(persistent=True), + ], # Override hover_tooltips to exclude the selector value hover_tooltips=list(unique_categories), # Don't include the selector heading From e8ab2328ea2c067c5ce85b634e7e22ed668d91ca Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 20 Jun 2025 18:24:39 +0200 Subject: [PATCH 50/55] simplify tests --- tests/test_manifold.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_manifold.py b/tests/test_manifold.py index f2a30ec..37872e2 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -12,11 +12,7 @@ import panel as pn import pytest -from hv_anndata.manifoldmap import ( - ManifoldMap, - create_manifoldmap_plot, - labeller, -) +from hv_anndata.manifoldmap import ManifoldMap, create_manifoldmap_plot, labeller @pytest.fixture @@ -72,7 +68,8 @@ def test_create_manifoldmap_plot_no_datashading( assert style_opts["size"] == 1 assert style_opts["alpha"] == 0.5 assert plot_opts["padding"] == 0 - assert plot_opts["tools"] == ["hover", "box_select", "lasso_select"] + assert len(plot_opts["tools"]) == 3 + assert "hover" in plot_opts["tools"] assert plot_opts["legend_position"] == "right" assert plot_opts["frame_width"] == 300 assert plot_opts["frame_height"] == 300 From ddcd751bf52bb410cde56197db8fc6d826b7ea5b Mon Sep 17 00:00:00 2001 From: maximlt Date: Mon, 23 Jun 2025 10:45:43 +0200 Subject: [PATCH 51/55] revert apply_when and bump holoviews --- pyproject.toml | 9 +++++++-- src/hv_anndata/manifoldmap.py | 36 +++++------------------------------ tests/test_manifold.py | 2 +- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1e7d2f9..9c7b9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,13 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dynamic = [ "description", "version" ] -dependencies = [ "anndata", "datashader", "holoviews>=1.21.0b3", "numpy", "panel" ] +dependencies = [ + "anndata", + "datashader", + "holoviews>=1.21.0rc0", + "numpy", + "panel", +] [tool.hatch.metadata.hooks.docstring-description] [tool.hatch.version] @@ -43,7 +49,6 @@ extra-dependencies = [ [tool.ruff] lint.select = [ "ALL" ] lint.ignore = [ - "ANN401", "B019", # functools.cache is fine to use "C408", # dict(...) calls are good "COM812", # Incompatible with formatter diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index 473d5ad..c99c395 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack +from typing import TYPE_CHECKING, Literal, TypedDict, Unpack import anndata as ad import bokeh @@ -15,7 +15,7 @@ import panel as pn import param from bokeh.models.tools import BoxSelectTool, LassoSelectTool -from holoviews.operation import Operation, apply_when +from holoviews.operation import Operation from panel.reactive import hold if TYPE_CHECKING: @@ -35,7 +35,6 @@ } DEFAULT_CAT_CMAP = cc.b_glasbey_category10 DEFAULT_CONT_CMAP = "viridis" -APPLY_WHEN_THRESHOLD = 1000 def _is_categorical(arr: np.ndarr) -> bool: @@ -227,34 +226,9 @@ def create_manifoldmap_plot( else: # For continuous data, take the mean aggregator = ds.mean(color_by) - - def operation(obj: Any) -> Any: - obj = hd.rasterize(obj, aggregator=aggregator) - # Applying opts here instead of after apply_when to ensure - # they're applied to the right element. - obj = obj.opts( - cmap=cmap, - colorbar=colorbar, - tools=[ - "hover", - BoxSelectTool(persistent=True), - LassoSelectTool(persistent=True), - ], - ) - return hd.dynspread(obj, threshold=0.5) - - plot = plot.opts( - tools=[ - "hover", - BoxSelectTool(persistent=True), - LassoSelectTool(persistent=True), - ] - ) - plot = apply_when( - plot, - operation=operation, - predicate=lambda obj: len(obj) > APPLY_WHEN_THRESHOLD, - ) + plot = hd.rasterize(plot, aggregator=aggregator) + plot = hd.dynspread(plot, threshold=0.5) + plot = plot.opts(cmap=cmap, colorbar=colorbar) if categorical and show_labels: # Options for labels diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 37872e2..7bf233a 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -127,7 +127,7 @@ def test_create_manifoldmap_plot_datashading( assert dop.p.threshold == 0.5 elif color_kind == "continuous": dm = plot.callback.inputs[0].callback.inputs[0] - rop = dm.callback.inputs[0].callback.inputs[0].callback.operation + rop = dm.callback.inputs[0].callback.operation assert rop.name == "rasterize" assert rop.p.aggregator.__class__.__name__ == "mean" assert rop.p.aggregator.column == color_var From 37aad73c173d801fdbd09cc707d7a46add5b7599 Mon Sep 17 00:00:00 2001 From: Demetris Roumis Date: Mon, 23 Jun 2025 12:33:02 +0200 Subject: [PATCH 52/55] add responsive and adaptive width height --- src/hv_anndata/manifoldmap.py | 45 ++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index c99c395..d7cfde3 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -98,9 +98,11 @@ class ManifoldMapConfig(TypedDict, total=False): """Configuration options for manifold map plotting.""" width: int - """width of the plot (default: 300)""" + """width of the plot (default: 300). + If responsive is True, this is the minimum width.""" height: int - """height of the plot (default: 300)""" + """minimum height of the plot (default: 300) + If responsive is True, this is the minimum height.""" datashading: bool """whether to apply datashader (default: True)""" show_labels: bool @@ -109,6 +111,8 @@ class ManifoldMapConfig(TypedDict, total=False): """colormap""" title: str """plot title (default: "")""" + responsive: bool + """whether to make the plot size-responsive. (default: True)""" def create_manifoldmap_plot( @@ -157,6 +161,7 @@ def create_manifoldmap_plot( show_labels = config.get("show_labels", False) cmap = config.get("cmap") title = config.get("title", "") + responsive = config.get("responsive", True) # Determine if color data is categorical if categorical is None: @@ -235,12 +240,23 @@ def create_manifoldmap_plot( label_opts = dict(text_font_size="8pt", text_color="black") plot = plot * labeller(dataset).opts(**label_opts) + if not responsive: + plot = plot.opts( + responsive=False, + frame_height=height, + frame_width=width, + ) + else: + plot = plot.opts( + responsive=True, + min_height=height, + min_width=width, + ) + # Apply final options to the plot return plot.opts( title=title, show_legend=show_legend, - frame_width=width, - frame_height=height, ) @@ -336,13 +352,17 @@ class ManifoldMap(pn.viewable.Viewer): datashade Whether to enable datashading width - Width of the plot + Minimum width of the plot. + If responsive is True, this is the minimum width. height - Height of the plot + Minimum height of the plot. + If responsive is True, this is the minimum height. show_labels Whether to show labels show_widgets Whether to show control widgets + responsive + Whether to make the plot size-responsive """ @@ -375,8 +395,8 @@ class ManifoldMap(pn.viewable.Viewer): to the index names if not set. """, ) - width: int = param.Integer(default=300, doc="Width of the plot") # type: ignore[assignment] - height: int = param.Integer(default=300, doc="Height of the plot") # type: ignore[assignment] + width: int = param.Integer(default=300, doc="Minimum width of the plot") # type: ignore[assignment] + height: int = param.Integer(default=300, doc="Minimum height of the plot") # type: ignore[assignment] show_labels: bool = param.Boolean( # type: ignore[assignment] default=False, label="Overlay Labels For Categorical Coloring", @@ -385,6 +405,10 @@ class ManifoldMap(pn.viewable.Viewer): show_widgets: bool = param.Boolean( # type: ignore[assignment] default=True, doc="Whether to show control widgets" ) + responsive: bool = param.Boolean( # type: ignore[assignment] + default=True, + doc="Whether to make the plot size-responsive", + ) _replot: bool = param.Event() # type: ignore[assignment] def __init__(self, **params: object) -> None: @@ -572,9 +596,10 @@ def create_plot( show_labels=show_labels, title=f"{dr_label}.{color_by}", cmap=cmap, + responsive=self.responsive, ) - return create_manifoldmap_plot( + self.plot = create_manifoldmap_plot( x_data, color_data, x_dim, @@ -586,6 +611,8 @@ def create_plot( **config, ) + return self.plot + @param.depends( # Only include derived parameters to avoid calling create_plot # unnecessarily. From c85cadd72fa30f413f8c5847cb8e1104101f7f3c Mon Sep 17 00:00:00 2001 From: maximlt Date: Mon, 23 Jun 2025 14:20:19 +0200 Subject: [PATCH 53/55] re-add tools for continuous --- src/hv_anndata/manifoldmap.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/hv_anndata/manifoldmap.py b/src/hv_anndata/manifoldmap.py index d7cfde3..0c70534 100644 --- a/src/hv_anndata/manifoldmap.py +++ b/src/hv_anndata/manifoldmap.py @@ -233,7 +233,15 @@ def create_manifoldmap_plot( aggregator = ds.mean(color_by) plot = hd.rasterize(plot, aggregator=aggregator) plot = hd.dynspread(plot, threshold=0.5) - plot = plot.opts(cmap=cmap, colorbar=colorbar) + plot = plot.opts( + cmap=cmap, + colorbar=colorbar, + tools=[ + "hover", + BoxSelectTool(persistent=True), + LassoSelectTool(persistent=True), + ], + ) if categorical and show_labels: # Options for labels From 1dc477b115fdd33858396064992d365cca9c1d5f Mon Sep 17 00:00:00 2001 From: maximlt Date: Mon, 23 Jun 2025 14:31:34 +0200 Subject: [PATCH 54/55] fix responsive related tests --- tests/test_manifold.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 7bf233a..0453b9c 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -71,8 +71,9 @@ def test_create_manifoldmap_plot_no_datashading( assert len(plot_opts["tools"]) == 3 assert "hover" in plot_opts["tools"] assert plot_opts["legend_position"] == "right" - assert plot_opts["frame_width"] == 300 - assert plot_opts["frame_height"] == 300 + assert plot_opts["min_width"] == 300 + assert plot_opts["min_height"] == 300 + assert plot_opts["responsive"] if color_kind == "categorical": assert ( @@ -202,6 +203,7 @@ def test_manifoldmap_create_plot(mock_cmp: Mock, sadata: ad.AnnData) -> None: show_labels=True, title="PCA.cell_type", cmap=["#1f77b3", "#ff7e0e"], + responsive=True, ) From b6f2abd30e7b183fa58017c3cfad5726e2a12ada Mon Sep 17 00:00:00 2001 From: maximlt Date: Mon, 23 Jun 2025 14:40:45 +0200 Subject: [PATCH 55/55] fix pipeline tests --- tests/test_manifold.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 0453b9c..154bd6a 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -108,7 +108,7 @@ def test_create_manifoldmap_plot_datashading( ) if color_kind == "categorical": - legend = plot.callback.inputs[0].callback.inputs[1] + legend = plot.callback.inputs[0].callback.inputs[0].callback.inputs[1] assert legend.keys() == ["A", "B"] assert all(legend[color].label == color for color in ["A", "B"]) assert ( @@ -118,7 +118,7 @@ def test_create_manifoldmap_plot_datashading( legend["B"].opts.get("style").kwargs["color"] == cc.b_glasbey_category10[1] ) - dm = plot.callback.inputs[0].callback.inputs[0] + dm = plot.callback.inputs[0].callback.inputs[0].callback.inputs[0] rop = dm.callback.inputs[0].callback.inputs[0].callback.operation assert rop.name == "rasterize" assert rop.p.aggregator.cat_column == color_var @@ -127,7 +127,7 @@ def test_create_manifoldmap_plot_datashading( assert dop.name == "dynspread" assert dop.p.threshold == 0.5 elif color_kind == "continuous": - dm = plot.callback.inputs[0].callback.inputs[0] + dm = plot.callback.inputs[0].callback.inputs[0].callback.inputs[0] rop = dm.callback.inputs[0].callback.operation assert rop.name == "rasterize" assert rop.p.aggregator.__class__.__name__ == "mean"