|
20 | 20 | from geopandas import GeoDataFrame |
21 | 21 | from matplotlib.axes import Axes |
22 | 22 | from matplotlib.backend_bases import RendererBase |
23 | | -from matplotlib.colors import Colormap, LogNorm, Normalize |
| 23 | +from matplotlib.colors import Colormap, LogNorm, Normalize, to_hex, to_rgba |
24 | 24 | from matplotlib.figure import Figure |
| 25 | +from matplotlib.legend import Legend |
25 | 26 | from mpl_toolkits.axes_grid1.inset_locator import inset_axes |
26 | 27 | from spatialdata._utils import _deprecation_alias |
27 | 28 | from spatialdata.transformations.operations import get_transformation |
|
31 | 32 | from spatialdata_plot._logging import _log_context, logger |
32 | 33 | from spatialdata_plot.pl._color import ( |
33 | 34 | _maybe_set_colors, |
| 35 | + _next_palette_colors, |
34 | 36 | _prepare_cmap_norm, |
35 | 37 | _set_outline, |
36 | 38 | ) |
@@ -1584,6 +1586,11 @@ def show( |
1584 | 1586 |
|
1585 | 1587 | _draw_scalebar(ax, scalebar_params_obj, panel_idx=i) |
1586 | 1588 |
|
| 1589 | + if fig_params.fig is not None: |
| 1590 | + for panel_ax in fig_params.axs if fig_params.axs is not None else [fig_params.ax]: |
| 1591 | + if isinstance(panel_ax, Axes): |
| 1592 | + _layout_panel_legends_rightward(panel_ax, fig_params.fig) |
| 1593 | + |
1587 | 1594 | _layout_pending_colorbars(pending_colorbars, fig_params, colorbar_params) |
1588 | 1595 |
|
1589 | 1596 | if fig_params.fig is not None and save is not None: |
@@ -1870,6 +1877,42 @@ def _draw_colorbar( |
1870 | 1877 | trackers_axes[location] = pad_axes + (bbox_axes.width if vertical else bbox_axes.height) |
1871 | 1878 |
|
1872 | 1879 |
|
| 1880 | +def _layout_panel_legends_rightward(ax: Axes, fig: Figure, gap: float = 0.01) -> None: |
| 1881 | + """Lay the per-render categorical legends (#364) left-to-right along the top of the right margin. |
| 1882 | +
|
| 1883 | + Only legends this code created (tagged ``_sdata_column``) are touched, so fill/outline and |
| 1884 | + channel legends keep their own placement. Layout is in figure-fraction so the axis shrink that |
| 1885 | + spreading them triggers under ``constrained_layout`` can't rescale the measured widths into overlap. |
| 1886 | + """ |
| 1887 | + legends = [c for c in ax.get_children() if isinstance(c, Legend) and hasattr(c, "_sdata_column")] |
| 1888 | + if len(legends) < 2: |
| 1889 | + return |
| 1890 | + # 2+ legends share the axis: title each by its column so they can be told apart (an explicit |
| 1891 | + # title set earlier stays as-is). |
| 1892 | + for leg in legends: |
| 1893 | + if not leg.get_title().get_text(): |
| 1894 | + leg.set_title(leg._sdata_column) |
| 1895 | + # Let constrained_layout settle the axes, then freeze it: otherwise it shrinks the axes to |
| 1896 | + # "make room" for the margin legends (squashing the plot, leaving a gap). Frozen, the legends |
| 1897 | + # still count for `bbox_inches="tight"` on save. Reached only for panels with 2+ legends. |
| 1898 | + fig.canvas.draw() |
| 1899 | + fig.set_layout_engine("none") |
| 1900 | + invf = fig.transFigure.inverted() |
| 1901 | + ax_bb = ax.get_window_extent().transformed(invf) |
| 1902 | + left, top = ax_bb.x1 + gap, ax_bb.y1 |
| 1903 | + # Anchor all at one point and settle, so widths are measured in a single consistent layout state. |
| 1904 | + for leg in legends: |
| 1905 | + leg.set_bbox_to_anchor((left, top), transform=fig.transFigure) |
| 1906 | + if hasattr(leg, "set_loc"): |
| 1907 | + leg.set_loc("upper left") |
| 1908 | + fig.canvas.draw() |
| 1909 | + widths = [leg.get_window_extent().transformed(invf).width for leg in legends] |
| 1910 | + x = left |
| 1911 | + for leg, w in zip(legends, widths, strict=True): |
| 1912 | + leg.set_bbox_to_anchor((x, top), transform=fig.transFigure) |
| 1913 | + x += w + gap |
| 1914 | + |
| 1915 | + |
1873 | 1916 | def _layout_pending_colorbars( |
1874 | 1917 | pending_colorbars: list[tuple[Axes, list[ColorbarSpec]]], |
1875 | 1918 | fig_params: FigParams, |
@@ -1944,19 +1987,32 @@ def _should_rasterize( |
1944 | 1987 | return scale is None or (isinstance(scale, str) and scale != "full" and (dpi is not None or figsize is not None)) |
1945 | 1988 |
|
1946 | 1989 |
|
1947 | | -def _maybe_set_label_colors(sdata: sd.SpatialData, render_params: LabelsRenderParams) -> None: |
1948 | | - """Materialize a categorical palette on the table annotating a labels element, if applicable.""" |
| 1990 | +def _maybe_set_label_colors( |
| 1991 | + sdata: sd.SpatialData, |
| 1992 | + render_params: LabelsRenderParams, |
| 1993 | + used_colors: set[str] | None = None, |
| 1994 | +) -> None: |
| 1995 | + """Materialize a categorical palette on the table annotating a labels element, if applicable. |
| 1996 | +
|
| 1997 | + ``used_colors`` accumulates the colors already taken by earlier categorical label renders on |
| 1998 | + the same panel. When a column's colors are auto-generated (no user palette, not already in |
| 1999 | + ``.uns``), they are shifted to skip ``used_colors`` so stacked legends stay distinct (#364). |
| 2000 | + """ |
1949 | 2001 | table = render_params.table_name |
1950 | | - if table is None or render_params.col_for_color is None: |
| 2002 | + col = render_params.col_for_color |
| 2003 | + if table is None or col is None: |
1951 | 2004 | return |
1952 | | - colors = sc.get.obs_df(sdata[table], [render_params.col_for_color]) |
1953 | | - if isinstance(colors[render_params.col_for_color].dtype, pd.CategoricalDtype): |
1954 | | - _maybe_set_colors( |
1955 | | - source=sdata[table], |
1956 | | - target=sdata[table], |
1957 | | - key=render_params.col_for_color, |
1958 | | - palette=render_params.palette, |
1959 | | - ) |
| 2005 | + colors = sc.get.obs_df(sdata[table], [col]) |
| 2006 | + if not isinstance(colors[col].dtype, pd.CategoricalDtype): |
| 2007 | + return |
| 2008 | + adata = sdata[table] |
| 2009 | + color_key = f"{col}_colors" |
| 2010 | + if render_params.palette is None and used_colors and color_key not in adata.uns: |
| 2011 | + adata.uns[color_key] = _next_palette_colors(used_colors, len(colors[col].cat.categories)) |
| 2012 | + else: |
| 2013 | + _maybe_set_colors(source=adata, target=adata, key=col, palette=render_params.palette) |
| 2014 | + if used_colors is not None and color_key in adata.uns: |
| 2015 | + used_colors.update(to_hex(to_rgba(c)) for c in adata.uns[color_key]) |
1960 | 2016 |
|
1961 | 2017 |
|
1962 | 2018 | def _render_panel( |
@@ -1985,6 +2041,9 @@ def _render_panel( |
1985 | 2041 | """ |
1986 | 2042 | wants = dict.fromkeys(("images", "labels", "points", "shapes"), False) |
1987 | 2043 | wanted_elements: list[str] = [] |
| 2044 | + # Colors already taken by categorical label renders on this panel, so later renders can |
| 2045 | + # avoid reusing them and their stacked legends stay distinct (#364). |
| 2046 | + used_label_colors: set[str] = set() |
1988 | 2047 |
|
1989 | 2048 | for cmd, params in render_cmds: |
1990 | 2049 | # Skip render entries that belong to a different color panel. Entries with no |
@@ -2033,7 +2092,7 @@ def _render_panel( |
2033 | 2092 | cast("ImageRenderParams | LabelsRenderParams", element_params), dpi, figsize |
2034 | 2093 | ) |
2035 | 2094 | if cmd == "render_labels": |
2036 | | - _maybe_set_label_colors(sdata, cast(LabelsRenderParams, element_params)) |
| 2095 | + _maybe_set_label_colors(sdata, cast(LabelsRenderParams, element_params), used_label_colors) |
2037 | 2096 | _RENDERERS[cmd](**kwargs) |
2038 | 2097 |
|
2039 | 2098 | # Panel finalization depends only on per-panel values, so run it once after the loop. |
|
0 commit comments