Source code for scplotkit.composition

"""Cell-type composition plots: stacked bars and clustered heatmaps.

All functions here work from a sample-by-cell-type proportion table
(``pd.crosstab`` under the hood), then offer several sample orderings:
as-is, hierarchically clustered, or clustered within groups of a metadata
column.
"""

from __future__ import annotations

import math
from collections.abc import Sequence
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib import font_manager
from matplotlib import patches as mpatches
from scipy.cluster.hierarchy import leaves_list, linkage
from scipy.spatial.distance import pdist

from ._config import PlotConfig
from ._palettes import resolve_palette
from ._style import save_figure
from ._utils import require_columns, resolve_sample_column

__all__ = [
    "cluster_samples",
    "cluster_grouped_samples",
    "stacked_barplots",
    "stacked_barplots_multi_meta",
    "composition_heatmap",
    "abundance_bubble_grid",
]


[docs] def cluster_samples(ct_props: pd.DataFrame) -> pd.Index: """Order samples by hierarchical (Ward) clustering of their proportions.""" dist_matrix = pdist(ct_props.values, metric="euclidean") linkage_matrix = linkage(dist_matrix, method="ward") return ct_props.index[leaves_list(linkage_matrix)]
[docs] def cluster_grouped_samples( ct_props: pd.DataFrame, adata, sample_column: str, order_by_column: str, ascending: bool = True ) -> list: """Order samples by clustering within each group of ``order_by_column``. Groups themselves are ordered by their first observed value, ascending or descending as requested; samples within a group are hierarchically clustered (or left as-is if the group has a single sample). """ group_labels = ( adata.obs.groupby(sample_column, observed=True)[order_by_column].first().sort_values(ascending=ascending) ) ordered = [] for group_value in group_labels.unique(): group_sample_ids = group_labels[group_labels == group_value].index group_props = ct_props.loc[group_sample_ids] if len(group_props) > 1: d = pdist(group_props.values, metric="euclidean") link = linkage(d, method="ward") ordered.extend(group_props.index[leaves_list(link)]) else: ordered.extend(group_props.index) return ordered
def _clean_label(label: str, strip_prefix: str | None) -> str: return label.replace(strip_prefix, "") if strip_prefix else label def _fit_legends_in_figure(fig, ax, legends): """Grow the figure canvas, if needed, so ``legends`` fit on it. ``bbox_inches="tight"`` (used by :func:`~scplotkit._style.save_figure`) only rescues legends registered figure-level via ``fig.legend()``; legends kept alive via ``ax.legend()`` + ``ax.add_artist`` are invisible to it, and Jupyter's inline display uses the same tight-bbox machinery. Given figure-level legends, growing the canvas itself so it physically contains them fixes both the saved file and the notebook-inline rendering -- legends anchored right of the axes push the canvas wider, legends anchored below it push the canvas taller. """ fig.canvas.draw() renderer = fig.canvas.get_renderer() dpi = fig.dpi fig_width_in, fig_height_in = fig.get_figwidth(), fig.get_figheight() fig_width_px, fig_height_px = fig_width_in * dpi, fig_height_in * dpi extents = [legend.get_window_extent(renderer) for legend in legends] max_right_px = max(e.x1 for e in extents) min_bottom_px = min(e.y0 for e in extents) ax_pos = ax.get_position() new_width_in, new_height_in = fig_width_in, fig_height_in adjust_kwargs = {} if max_right_px > fig_width_px: extra_in = (max_right_px - fig_width_px) / dpi + 0.15 new_width_in = fig_width_in + extra_in adjust_kwargs["right"] = (ax_pos.x1 * fig_width_in) / new_width_in if min_bottom_px < 0: extra_in = -min_bottom_px / dpi + 0.15 new_height_in = fig_height_in + extra_in adjust_kwargs["bottom"] = (ax_pos.y0 * fig_height_in + extra_in) / new_height_in adjust_kwargs["top"] = (ax_pos.y1 * fig_height_in + extra_in) / new_height_in if not adjust_kwargs: return fig.set_size_inches(new_width_in, new_height_in, forward=True) fig.subplots_adjust(**adjust_kwargs) def _order_color_strip(ax, order_values, order_color_dict, bar_width, y_offset, height): for i, val in enumerate(order_values): ax.add_patch( plt.Rectangle( (i - bar_width / 2, y_offset), bar_width, height, linewidth=0, edgecolor=order_color_dict[val], facecolor=order_color_dict[val], transform=ax.transData, clip_on=False, ) ) def _plot_stacked_bar( ct_props, color_dict, title, ylabel, figsize, sample_column, order_values=None, order_color_dict=None, order_by_column=None, strip_prefix=None, config: PlotConfig = None, ): general = config.general fig, ax = plt.subplots(figsize=figsize) bottom = np.zeros(len(ct_props)) bar_width = 1 x_positions = np.arange(len(ct_props)) for cell_type in ct_props.columns: values = ct_props[cell_type].values ax.bar( x_positions, values, bottom=bottom, width=bar_width, color=color_dict.get(cell_type, general["fallback_color"]), label=_clean_label(cell_type, strip_prefix), edgecolor="white", linewidth=1.0, ) bottom += values ax.set_xlabel("") ax.set_ylabel(ylabel, fontsize=12) ax.set_xticks([]) ax.set_xticklabels([]) ax.set_title(title, fontsize=general["title_fontsize"], fontweight=general["title_fontweight"], pad=20) ax.set_yticks([0, 0.25, 0.5, 0.75, 1.0]) ax.set_yticklabels(["0%", "25%", "50%", "75%", "100%"]) ax.set_xlim(-0.5, len(ct_props) - 0.5) ax.set_ylim(0, 1.0) ax.set_axisbelow(True) if order_values is not None and order_color_dict is not None: _order_color_strip( ax, order_values, order_color_dict, bar_width, y_offset=-0.035, height=0.025 ) handles = [ plt.Rectangle((0, 0), 1, 1, color=color_dict.get(k, general["fallback_color"])) for k in ct_props.columns ] labels = [_clean_label(k, strip_prefix) for k in ct_props.columns] # fig.legend() (rather than ax.legend() + ax.add_artist()) so this legend is picked up by # matplotlib's own tight-bbox accounting -- ax.add_artist'd legends are invisible to it, which # is what let this legend get silently cropped by bbox_inches="tight" (the default for both # save_figure()'s save and Jupyter's inline display) whenever it overflowed the axes. legend1 = fig.legend( handles, labels, bbox_to_anchor=(1.01, 1), bbox_transform=ax.transAxes, loc="upper left", title="Cell Types", title_fontproperties=font_manager.FontProperties(weight="bold"), fontsize=general["legend_fontsize"], frameon=False, ) legends = [legend1] if order_values is not None and order_color_dict is not None and order_by_column: order_handles = [plt.Rectangle((0, 0), 1, 1, color=order_color_dict[v]) for v in order_color_dict] order_labels = [str(v) for v in order_color_dict] legend2 = fig.legend( order_handles, order_labels, bbox_to_anchor=(1.01, 0), bbox_transform=ax.transAxes, loc="lower left", title=order_by_column.replace("_", " ").title(), frameon=False, ) legend2.get_title().set_fontweight("bold") legends.append(legend2) fig.subplots_adjust(right=0.78) _fit_legends_in_figure(fig, ax, legends) return fig
[docs] def stacked_barplots( adata, level_column: str, sample_column: str | None = "Sample_ID", subset_level: str | None = None, subset_value: str | Sequence | None = None, order_by_column: str | None = None, order_ascending: bool = True, figsize: tuple = (16, 8), palette: dict | None = None, order_palette: dict | None = None, strip_prefix: str | None = None, save_name_prefix: str = "composition", config: PlotConfig | None = None, output_dir: str | Path | None = "figures", ) -> dict: """Stacked bar plots of cell-type proportion per sample. Returns a dict with up to three variants -- ``"basic"``, ``"clustered"`` (samples ordered by hierarchical clustering), and ``"clustered_grouped"`` (present only if ``order_by_column`` is given: samples clustered within each group of that column). """ config = PlotConfig.load(config) sample_column = resolve_sample_column(adata, sample_column) require_columns(adata, level_column, sample_column, subset_level, order_by_column) if subset_level and subset_value is not None: subset_value = [subset_value] if isinstance(subset_value, str) else subset_value adata = adata[adata.obs[subset_level].isin(subset_value), :] ct_data = pd.crosstab(adata.obs[sample_column], adata.obs[level_column]) ct_props = ct_data.div(ct_data.sum(axis=1), axis=0) color_dict = resolve_palette(config, level_column, ct_props.columns, palette) ylabel = f"Cell Type Proportion ({subset_value[0]}s)" if subset_value else "Cell Type Proportion" title_base = f"Cell Type Composition by {sample_column.replace('_', ' ').title()}" order_color_dict = None if order_by_column: order_vals_all = adata.obs.groupby(sample_column, observed=True)[order_by_column].first() order_color_dict = resolve_palette(config, order_by_column, order_vals_all.unique(), order_palette) def order_values_for(index): if not order_by_column: return None return [adata.obs[adata.obs[sample_column] == s][order_by_column].iloc[0] for s in index] figs = { "basic": _plot_stacked_bar( ct_props, color_dict, title_base, ylabel, figsize, sample_column, order_values_for(ct_props.index), order_color_dict, order_by_column, strip_prefix, config, ) } clustered_order = cluster_samples(ct_props) figs["clustered"] = _plot_stacked_bar( ct_props.loc[clustered_order], color_dict, f"{title_base} (Clustered)", ylabel, figsize, sample_column, order_values_for(clustered_order), order_color_dict, order_by_column, strip_prefix, config, ) if order_by_column: grouped_order = cluster_grouped_samples(ct_props, adata, sample_column, order_by_column, order_ascending) figs["clustered_grouped"] = _plot_stacked_bar( ct_props.loc[grouped_order], color_dict, f"{title_base} (Clustered per Group)", ylabel, figsize, sample_column, order_values_for(grouped_order), order_color_dict, order_by_column, strip_prefix, config, ) for key, fig in figs.items(): save_figure( fig, output_dir, "compositional_plot", f"{save_name_prefix}_{key}.png", config, include_legends=True ) return figs
def _plot_stacked_bar_multi_meta( ct_props, color_dict, title, ylabel, figsize, sample_column, meta_annotations_list, meta_color_dicts_list, meta_columns_list, xlabel=None, strip_prefix=None, config: PlotConfig = None, ): general = config.general fig, ax = plt.subplots(figsize=figsize) bottom = np.zeros(len(ct_props)) bar_width = 1 x_positions = np.arange(len(ct_props)) for cell_type in ct_props.columns: values = ct_props[cell_type].values ax.bar( x_positions, values, bottom=bottom, width=bar_width, color=color_dict.get(cell_type, general["fallback_color"]), label=_clean_label(cell_type, strip_prefix), edgecolor="white", linewidth=1.0, ) bottom += values ax.set_xlabel("", fontsize=12) ax.set_ylabel(ylabel, fontsize=12) ax.set_xticks([]) ax.set_xticklabels([]) ax.set_title(title, fontsize=general["title_fontsize"], fontweight=general["title_fontweight"], pad=20) ax.set_yticks([0, 0.25, 0.5, 0.75, 1.0]) ax.set_yticklabels(["0%", "25%", "50%", "75%", "100%"]) ax.set_xlim(-0.5, len(ct_props) - 0.5) ax.set_ylim(0, 1.0) ax.set_axisbelow(True) if meta_annotations_list and meta_color_dicts_list and meta_columns_list: bar_height = 0.025 y_offset_base = -bar_height - 0.01 for idx, (annotations, color_dict_meta, _col) in enumerate( zip(meta_annotations_list, meta_color_dicts_list, meta_columns_list) ): y_offset = y_offset_base - idx * (bar_height + 0.005) for i, val in enumerate(annotations): color = color_dict_meta.get(val, general["fallback_color"]) ax.add_patch( mpatches.Rectangle( (i - bar_width / 2, y_offset), bar_width, bar_height, linewidth=0, edgecolor=color, facecolor=color, transform=ax.transData, clip_on=False, ) ) handles = [ plt.Rectangle((0, 0), 1, 1, color=color_dict.get(k, general["fallback_color"])) for k in ct_props.columns ] labels = [_clean_label(k, strip_prefix) for k in ct_props.columns] legend1 = fig.legend( handles, labels, bbox_to_anchor=(1.01, 1), bbox_transform=ax.transAxes, loc="upper left", title="Cell Types", title_fontproperties=font_manager.FontProperties(weight="bold"), fontsize=general["legend_fontsize"], frameon=False, ) legends = [legend1] if meta_annotations_list and meta_color_dicts_list and meta_columns_list: current_y_anchor = -0.15 for idx, (color_dict_meta, column_name) in enumerate(zip(meta_color_dicts_list, meta_columns_list)): present_values = sorted(set(meta_annotations_list[idx]), key=str) order_handles = [ plt.Rectangle((0, 0), 1, 1, color=color_dict_meta[v]) for v in present_values if v in color_dict_meta ] order_labels = [str(v) for v in present_values if v in color_dict_meta] if not order_handles: continue # fig.legend() (rather than ax.legend() + ax.add_artist()) so this legend is picked up # by matplotlib's own tight-bbox accounting -- see _fit_legends_in_figure. legend = fig.legend( order_handles, order_labels, bbox_to_anchor=(0.0, current_y_anchor), bbox_transform=ax.transAxes, loc="upper left", title=column_name.replace("_", " ").title(), frameon=False, fontsize=general["legend_fontsize"], ncol=1, ) legend.get_title().set_fontweight("bold") legends.append(legend) fig.canvas.draw() bbox = legend.get_window_extent(fig.canvas.get_renderer()) figure_height = fig.get_figheight() * fig.dpi current_y_anchor -= (bbox.height / figure_height) + 0.05 fig.subplots_adjust(right=0.78) _fit_legends_in_figure(fig, ax, legends) return fig
[docs] def stacked_barplots_multi_meta( adata, level_column: str, sample_column: str | None = "Sample_ID", metadata_columns: Sequence[str] | None = None, subset_level: str | None = None, subset_value: str | Sequence | None = None, order_by_column: str | None = None, order_ascending: bool = True, figsize: tuple = (16, 8), palette: dict | None = None, meta_palettes: dict[str, dict] | None = None, xlabel: str | None = None, strip_prefix: str | None = None, save_name_prefix: str = "composition_multi_meta", config: PlotConfig | None = None, output_dir: str | Path | None = "figures", ) -> dict: """Like :func:`stacked_barplots`, with extra color-coded metadata strips drawn below the bars (e.g. treatment, timepoint, batch).""" config = PlotConfig.load(config) meta_palettes = meta_palettes or {} metadata_columns = list(metadata_columns or []) sample_column = resolve_sample_column(adata, sample_column) require_columns(adata, level_column, sample_column, subset_level, order_by_column) if subset_level and subset_value is not None: subset_value = [subset_value] if isinstance(subset_value, str) else subset_value adata = adata[adata.obs[subset_level].isin(subset_value), :] if order_by_column and order_by_column not in metadata_columns: metadata_columns.insert(0, order_by_column) valid_meta_columns = [c for c in metadata_columns if c in adata.obs.columns] ct_data = pd.crosstab(adata.obs[sample_column], adata.obs[level_column]) ct_props = ct_data.div(ct_data.sum(axis=1), axis=0) color_dict = resolve_palette(config, level_column, ct_props.columns, palette) ylabel = f"Cell Type Proportion ({subset_value[0]}s)" if subset_value else "Cell Type Proportion" title_base = f"Cell Type Composition by {sample_column.replace('_', ' ').title()}" meta_color_dicts = [ resolve_palette(config, col, adata.obs[col].unique(), meta_palettes.get(col)) for col in valid_meta_columns ] def get_meta_annotations(order): return [[adata.obs[adata.obs[sample_column] == s][col].iloc[0] for s in order] for col in valid_meta_columns] figs = { "basic": _plot_stacked_bar_multi_meta( ct_props, color_dict, title_base, ylabel, figsize, sample_column, get_meta_annotations(ct_props.index), meta_color_dicts, valid_meta_columns, xlabel, strip_prefix, config, ) } clustered_order = cluster_samples(ct_props) figs["clustered"] = _plot_stacked_bar_multi_meta( ct_props.loc[clustered_order], color_dict, f"{title_base} (Clustered)", ylabel, figsize, sample_column, get_meta_annotations(clustered_order), meta_color_dicts, valid_meta_columns, xlabel, strip_prefix, config, ) if order_by_column: grouped_order = cluster_grouped_samples(ct_props, adata, sample_column, order_by_column, order_ascending) figs["clustered_grouped"] = _plot_stacked_bar_multi_meta( ct_props.loc[grouped_order], color_dict, f"{title_base} (Clustered per Group)", ylabel, figsize, sample_column, get_meta_annotations(grouped_order), meta_color_dicts, valid_meta_columns, xlabel, strip_prefix, config, ) for key, fig in figs.items(): save_figure( fig, output_dir, "compositional_plot_multi_meta", f"{save_name_prefix}_{key}.png", config, include_legends=True, ) return figs
[docs] def composition_heatmap( adata, level_column: str, sample_column: str | None = "Sample_ID", subset_level: str | None = None, subset_value: str | Sequence | None = None, order_by_column: str | None = None, order_palette: dict | None = None, figsize: tuple = (16, 6), title: str | None = None, cmap: str | None = None, save_name: str | None = None, config: PlotConfig | None = None, output_dir: str | Path | None = "figures", **heatmap_kwargs, ): """Clustered heatmap of cell-type proportion per sample.""" import seaborn as sns config = PlotConfig.load(config) sample_column = resolve_sample_column(adata, sample_column) require_columns(adata, level_column, sample_column, subset_level, order_by_column) if subset_level is not None and subset_value is not None: subset_value = [subset_value] if isinstance(subset_value, str) else subset_value mask = adata.obs[subset_level].isin(subset_value) adata = adata[mask, :] print(f"Subset to {mask.sum()} cells with {subset_level} in {subset_value}") ct_data = pd.crosstab(adata.obs[sample_column], adata.obs[level_column]) ct_props = ct_data.div(ct_data.sum(axis=1), axis=0) dist_matrix = pdist(ct_props.values, metric="euclidean") linkage_matrix = linkage(dist_matrix, method="ward") ct_props = ct_props.iloc[leaves_list(linkage_matrix)] order_color_dict = None order_bar_colors = None unique_vals = None if order_by_column: order_values = [adata.obs[adata.obs[sample_column] == s][order_by_column].iloc[0] for s in ct_props.index] unique_vals = list(dict.fromkeys(order_values)) order_color_dict = resolve_palette(config, order_by_column, unique_vals, order_palette) order_bar_colors = [order_color_dict[v] for v in order_values] general = config.general fig, ax = plt.subplots(figsize=figsize) sns.heatmap( ct_props.T, annot=False, cmap=cmap or config["continuous"]["cmap"], ax=ax, cbar=True, linewidths=0, linecolor="white", xticklabels=False, yticklabels=True, square=False, **heatmap_kwargs, ) ax.set_aspect("auto") ax.set_title( title or "Cell Type Composition (Clustered)", fontsize=general["title_fontsize"], fontweight=general["title_fontweight"], ) ax.set_xlabel("") ax.set_ylabel(f"Cell Types ({subset_value[0]}s)" if subset_value else "Cell Types", fontsize=12) ax.set_xticks([]) ax.set_xticklabels([]) ax.set_yticklabels(ct_props.columns, rotation=0) if order_by_column: bar_height = 0.5 for idx, color in enumerate(order_bar_colors): ax.add_patch( plt.Rectangle( (idx, ct_props.shape[1] + 0.05), 1, bar_height, linewidth=0, color=color, transform=ax.transData, clip_on=False, ) ) legend_handles = [plt.Rectangle((0, 0), 1, 1, color=order_color_dict[v]) for v in unique_vals] legend_labels = [str(v) for v in unique_vals] legend = fig.legend( legend_handles, legend_labels, title=order_by_column.replace("_", " ").title(), loc="lower center", bbox_to_anchor=(0.5, -0.12), ncol=len(unique_vals), fontsize=general["legend_fontsize"], title_fontsize=general["legend_fontsize"], frameon=False, ) legend.get_title().set_fontweight("bold") plt.tight_layout(rect=[0, 0.01, 1, 1]) if save_name is not None: save_figure(fig, output_dir, "compositional_plot", f"{save_name}.png", config) return fig
def _size_legend_values(max_count: int, n: int = 4) -> list[int]: """Pick ``n`` round reference counts spanning roughly 0..``max_count`` for a size legend.""" if max_count <= 0: return [0] values = set() for v in np.linspace(max_count / n, max_count, n): magnitude = 10 ** math.floor(math.log10(v)) if v >= 1 else 1 values.add(int(round(v / magnitude) * magnitude)) return sorted(values)
[docs] def abundance_bubble_grid( adata, level_column: str, sample_column: str | None = "Sample_ID", subset_level: str | None = None, subset_value: str | Sequence | None = None, dot_color: str = "#37474f", min_size: float = 20.0, max_size: float = 500.0, figsize: tuple | None = None, title: str | None = None, xlabel: str | None = None, save_name: str = "abundance_bubble_grid", config: PlotConfig | None = None, output_dir: str | Path | None = "figures", ): """Dot grid of cell counts per (cell type, sample/dataset) pair. Rows are ``level_column`` categories, columns are ``sample_column`` values (any per-sample or per-dataset column works); dot area encodes cell count. A single neutral ``dot_color`` is used throughout rather than a per-category palette, so size alone carries the value. Pairs with zero cells are drawn as a faint open ring rather than left blank, so gaps in atlas coverage/balance across studies are visible rather than ambiguous with "not shown". Rows and columns are both ordered by descending total count, so the best-covered cell types and samples/datasets end up in the top-left. Purely descriptive -- raw cell counts only, no proportions and no statistical framing. Parameters ---------- adata: Annotated data. level_column: ``adata.obs`` column defining the rows (e.g. cell type). sample_column: ``adata.obs`` column defining the columns (sample or dataset ID). Defaults to the first object/category column if not found. subset_level, subset_value: Optionally restrict to cells where ``subset_level`` is in ``subset_value`` before counting (e.g. one lineage). dot_color: Fill color used for every filled dot. min_size, max_size: Marker area (in points^2) for the smallest nonzero and the largest count, respectively. save_name: Filename (without extension) to save under. Pass ``None`` to skip saving. config: Active :class:`~scplotkit.PlotConfig`. Uses defaults if omitted. output_dir: Directory figures are written under. """ config = PlotConfig.load(config) sample_column = resolve_sample_column(adata, sample_column) require_columns(adata, level_column, sample_column, subset_level) if subset_level and subset_value is not None: subset_value = [subset_value] if isinstance(subset_value, str) else subset_value adata = adata[adata.obs[subset_level].isin(subset_value), :] counts = pd.crosstab(adata.obs[level_column], adata.obs[sample_column]) counts = counts.loc[ counts.sum(axis=1).sort_values(ascending=False).index, counts.sum(axis=0).sort_values(ascending=False).index, ] n_rows, n_cols = counts.shape max_count = int(counts.values.max()) def size_for(count: int) -> float: return min_size + (max_size - min_size) * (count / max_count) if max_count > 0 else min_size xs, ys, sizes, zero_xs, zero_ys = [], [], [], [], [] for yi, cell_type in enumerate(counts.index): for xi, sample in enumerate(counts.columns): count = counts.loc[cell_type, sample] if count > 0: xs.append(xi) ys.append(yi) sizes.append(size_for(count)) else: zero_xs.append(xi) zero_ys.append(yi) general = config.general figsize = figsize or (max(6.0, n_cols * 0.45 + 3.0), max(4.0, n_rows * 0.4 + 2.0)) fig, ax = plt.subplots(figsize=figsize) ax.set_axisbelow(True) ax.grid(True, color="#e5e5e5", linewidth=0.6, zorder=0) zero_color = general.get("fallback_color", "lightgrey") if zero_xs: ax.scatter(zero_xs, zero_ys, s=min_size, facecolors="none", edgecolors=zero_color, linewidths=0.9, zorder=2) ax.scatter(xs, ys, s=sizes, color=dot_color, alpha=0.85, edgecolors="white", linewidths=0.6, zorder=3) ax.set_xlim(-0.6, n_cols - 0.4) ax.set_ylim(-0.6, n_rows - 0.4) ax.set_xticks(range(n_cols)) ax.set_xticklabels(counts.columns, rotation=90) ax.set_yticks(range(n_rows)) ax.set_yticklabels(counts.index) ax.invert_yaxis() # most abundant cell type at the top for spine in ax.spines.values(): spine.set_visible(False) ax.set_xlabel(xlabel or sample_column.replace("_", " ").title(), fontsize=12) ax.set_ylabel(level_column.replace("_", " ").title(), fontsize=12) ax.set_title( title or "Cell Type Abundance Across Samples", fontsize=general["title_fontsize"], fontweight=general["title_fontweight"], pad=20, ) legend_values = _size_legend_values(max_count) legend_handles = [ plt.scatter([], [], s=size_for(v), color=dot_color, alpha=0.85, edgecolors="white", linewidths=0.6) for v in legend_values ] + [plt.scatter([], [], s=min_size, facecolors="none", edgecolors=zero_color, linewidths=0.9)] legend_labels = [str(v) for v in legend_values] + ["0"] legend1 = fig.legend( legend_handles, legend_labels, bbox_to_anchor=(1.01, 1), bbox_transform=ax.transAxes, loc="upper left", title="Cell Count", title_fontproperties=font_manager.FontProperties(weight="bold"), fontsize=general["legend_fontsize"], frameon=False, labelspacing=1.4, ) fig.subplots_adjust(right=0.78) _fit_legends_in_figure(fig, ax, [legend1]) if save_name is not None: save_figure(fig, output_dir, "abundance_bubble_grid", f"{save_name}.png", config, include_legends=True) return fig