Source code for figrecipe._wrappers._panel_labels

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Panel label utilities for multi-panel figures."""

import string
from typing import TYPE_CHECKING, Any, List, Optional, Tuple

if TYPE_CHECKING:
    from ._axes import RecordingAxes


def add_panel_labels(
    all_axes: List["RecordingAxes"],
    labels: Optional[List[str]],
    loc: str,
    offset: Tuple[float, float],
    fontsize: float,
    fontweight: str,
    text_color: str,
    record_callback: Any,
    title_aware: bool = False,
    **kwargs,
) -> List[Any]:
    """Add panel labels (A, B, C, D, etc.) to axes.

    Parameters
    ----------
    all_axes : list of RecordingAxes
        Flattened list of all axes.
    labels : list of str or None
        Custom labels. If None, uses uppercase letters.
    loc : str
        Location hint: 'upper left', 'upper right', 'lower left', 'lower right'.
    offset : tuple of float
        (x, y) offset in axes coordinates.
    fontsize : float
        Font size in points.
    fontweight : str
        Font weight.
    text_color : str
        Text color.
    record_callback : callable
        Callback to record panel labels info.
    title_aware : bool
        When True (the default-offset upper-position path), each label's y is
        lifted ABOVE its axes title at draw time so the label never renders ON
        the title. The lift is evaluated lazily because ``panel_labels=True``
        adds labels BEFORE the user sets titles (via ``set_xyt``). Has no effect
        for an axes without a title (back-compat). When False (an explicit
        user offset) the offset is honored verbatim.
    **kwargs
        Additional arguments passed to ax.text().

    Returns
    -------
    list of Text
        The matplotlib Text objects created.
    """
    n_axes = len(all_axes)

    # Generate default labels if not provided
    if labels is None:
        labels = list(string.ascii_uppercase[:n_axes])
    elif len(labels) < n_axes:
        # Extend with letters if not enough labels provided
        labels = list(labels) + list(string.ascii_uppercase[len(labels) : n_axes])

    # Calculate position based on loc
    x, y, ha, va = _calculate_position(loc, offset)

    # Record panel labels
    record_callback(
        {
            "labels": labels[:n_axes],
            "loc": loc,
            "offset": offset,
            "fontsize": fontsize,
            "fontweight": fontweight,
            "color": text_color,
            "kwargs": kwargs,
        }
    )

    # Add labels to each axes
    text_objects = []
    for ax, label in zip(all_axes, labels[:n_axes]):
        text = ax.ax.text(
            x,
            y,
            label,
            transform=ax.ax.transAxes,
            fontsize=fontsize,
            fontweight=fontweight,
            color=text_color,
            ha=ha,
            va=va,
            **kwargs,
        )
        if title_aware:
            # Tag the label so the save-time finalizer can lift it clear of
            # the axes title. We tag (rather than position now) because
            # ``panel_labels=True`` adds labels BEFORE the user sets titles via
            # ``set_xyt``; the title height is only known at draw/save time.
            text._figrecipe_panel_label_base_y = y
        text_objects.append(text)

    return text_objects


def finalize_panel_labels(mpl_fig: Any) -> None:
    """Lift title-aware panel labels above titles for a whole figure.

    Save-time entry point: draws the figure once (so titles exist and extents
    are measurable), then adjusts every axes' panel labels. Safe to call when
    no title-aware labels are present (it is a no-op then).

    Parameters
    ----------
    mpl_fig : matplotlib.figure.Figure
        The figure whose axes' panel labels to finalize.
    """
    mpl_fig.canvas.draw()
    renderer = mpl_fig.canvas.get_renderer()
    for mpl_ax in mpl_fig.get_axes():
        clear_panel_labels_above_titles(mpl_ax, renderer)


def clear_panel_labels_above_titles(mpl_ax: Any, renderer: Any) -> None:
    """Lift title-aware panel labels above their axes title.

    Called at save/finalize time (when titles exist and a renderer is
    available) for every axes. For each panel label tagged title-aware (see
    ``add_panel_labels``), if the axes has a non-empty title the label's y is
    raised to sit ABOVE the title by the title's height in axes-fraction plus a
    small pad, so it never renders on the title. When the axes has no title the
    label keeps its original base y -- preserving the default placement
    (back-compat). Idempotent: re-running recomputes from the stored base y.

    Parameters
    ----------
    mpl_ax : matplotlib.axes.Axes
        The axes whose panel-label texts to adjust.
    renderer : RendererBase
        An active renderer for measuring title/axes extents.
    """
    pad_fraction = 0.02  # gap between title top and label, in axes-fraction

    fig = mpl_ax.figure
    title = mpl_ax.get_title()

    ax_bbox = mpl_ax.get_window_extent(renderer)
    ax_height_pt = ax_bbox.height * 72.0 / fig.dpi

    for text in list(mpl_ax.texts):
        base_y = getattr(text, "_figrecipe_panel_label_base_y", None)
        if base_y is None:
            continue

        if not title or ax_height_pt <= 0:
            # No title -> restore default placement.
            _set_label_y(text, base_y)
            continue

        title_bbox = mpl_ax.title.get_window_extent(renderer)
        title_height_pt = title_bbox.height * 72.0 / fig.dpi
        title_height_frac = title_height_pt / ax_height_pt

        target_y = max(base_y, 1.0 + title_height_frac + pad_fraction)
        _set_label_y(text, target_y)


def _set_label_y(text: Any, y: float) -> None:
    """Set a Text's y position (axes-fraction) without disturbing its x."""
    x, cur_y = text.get_position()
    if cur_y != y:
        text.set_position((x, y))


def _calculate_position(
    loc: str, offset: Tuple[float, float]
) -> Tuple[float, float, str, str]:
    """Calculate text position and alignment based on location.

    Returns
    -------
    tuple
        (x, y, ha, va) where ha/va are horizontal/vertical alignment.
    """
    if loc == "upper left":
        x, y = offset
        ha, va = "right", "bottom"
    elif loc == "upper right":
        x, y = offset
        ha, va = "left", "bottom"
    elif loc == "lower left":
        x, y = offset[0], -offset[1] + 1.0
        ha, va = "right", "top"
    elif loc == "lower right":
        x, y = offset
        ha, va = "left", "top"
    else:
        x, y = offset
        ha, va = "right", "bottom"

    return x, y, ha, va


__all__ = [
    "add_panel_labels",
    "panel_label",
    "clear_panel_labels_above_titles",
    "finalize_panel_labels",
]


[docs] def panel_label( ax: Any, label: str, loc: str = "upper left", offset: Tuple[float, float] = (-0.1, 1.05), fontsize: float = 10, fontweight: str = "bold", text_color: str = "black", **kwargs, ) -> Any: """Place a single panel label (A, B, C, etc.) on one axes. Parameters ---------- ax : matplotlib.axes.Axes The axes to annotate. label : str The panel label text (e.g. 'A', 'B', '(1)'). loc : str Location hint: 'upper left' (default), 'upper right', 'lower left', 'lower right'. offset : tuple of float (x, y) offset in axes coordinates. Default (-0.1, 1.05). fontsize : float Font size in points (default 10). fontweight : str Font weight (default 'bold'). text_color : str Text color (default 'black'). **kwargs Additional arguments passed to ``ax.text()``. Returns ------- matplotlib.text.Text The created text annotation. """ x, y, ha, va = _calculate_position(loc, offset) return ax.ax.text( x, y, label, transform=ax.ax.transAxes, fontsize=fontsize, fontweight=fontweight, color=text_color, ha=ha, va=va, **kwargs, )
# EOF