Source code for dartwork_mpl.annotation

"""Axes-based annotation helper module.

Provides ``label_axes`` for adding standard alphabetic sub-labels to figure
panels, and ``arrow_axis`` for drawing bidirectional Low–High arrow axes.
"""

from __future__ import annotations

__all__ = ["label_axes", "arrow_axis"]

import string
from typing import Any

import numpy as np
from matplotlib.axes import Axes

from .scale import fs


[docs] def label_axes( axes: list[Axes] | np.ndarray, labels: list[str] | None = None, fontsize: float = 10, fontweight: str = "bold", x: float | str = "auto", y: float = 1.05, **kwargs, ) -> list: """Add standardized identification labels (a, b, c, ...) to subplot panels. Commonly used in academic papers and reports to annotate multiple panels of a figure, placing labels at the left edge or top corner of each Axes. Parameters ---------- axes : list[Axes] | np.ndarray List or array of Axes objects to label. labels : list[str] | None, optional Custom text labels. If None, lowercase letters (a, b, c, ...) are assigned automatically. fontsize : float, optional Font size for the labels. Default is 10 points. fontweight : str, optional Font weight for the labels. Default is "bold". x : float | str, optional Horizontal position in Axes-relative coordinates (may exceed 0.0–1.0). If "auto", the optimal x position is determined based on whether a y-axis label is present (-0.18 or -0.02). y : float, optional Vertical position in Axes-relative coordinates. Default is 1.05. **kwargs Additional text properties passed to ``ax.text()``. Returns ------- list List of created Text objects. """ if isinstance(axes, np.ndarray): axes = axes.flatten().tolist() if labels is None: labels = list(string.ascii_lowercase[: len(axes)]) texts = [] for ax, label in zip(axes, labels, strict=False): if x == "auto": has_ylabel = ax.get_ylabel().strip() != "" x_pos = -0.18 if has_ylabel else -0.02 else: x_pos = float(x) # type: ignore[arg-type] t = ax.text( x_pos, y, label, transform=ax.transAxes, fontsize=fontsize, fontweight=fontweight, va="bottom", ha="left", **kwargs, ) texts.append(t) return texts
[docs] def arrow_axis( ax: Axes, direction: str, label: str, *, offset: float = -0.10, low: str = "Low", high: str = "High", fontsize: float | None = None, fontsize_label: float | None = None, pad: float = -0.005, weight: str = "normal", color: str = "black", arrow_kw: dict | None = None, ) -> None: """Draw a bidirectional Low–High arrow axis along the edge of a plot. Produces a visual like ``Low ◄── label ──► High`` near the spine exterior. Parameters ---------- ax : Axes Target Axes object for the annotation. direction : {'x', 'y'} "x": insert a horizontal arrow axis below the x-axis spine. "y": insert a vertical arrow axis to the left of the y-axis spine. label : str Center label text placed at the midpoint of the axis. offset : float, optional Offset from the spine in Axes-fraction units. Default is -0.10 (sufficiently outside to avoid overlap with tick labels). low : str, optional Text for the low end (bottom/left) of the axis. Default is "Low". high : str, optional Text for the high end (top/right) of the axis. Default is "High". fontsize : float | None, optional Font size for the Low/High endpoint labels. Default is fs(-1). fontsize_label : float | None, optional Font size for the center label. Default is fs(0). pad : float, optional Fractional gap between text and arrowheads. Default is -0.005. weight : str, optional Font weight applied to all text elements. color : str, optional Color for both text and arrows. Default is "black". arrow_kw : dict | None, optional Override the arrowprops passed to the internal ``ax.annotate`` calls. """ if fontsize is None: fontsize = fs(-1) if fontsize_label is None: fontsize_label = fs(0) if arrow_kw is None: arrow_kw = { "arrowstyle": "-|>,head_width=0.1", "color": color, "lw": 0.25, } fig = ax.get_figure() if fig is None or fig.canvas is None: raise ValueError("Axes must be part of a Figure with a canvas") renderer = fig.canvas.get_renderer() # type: ignore[attr-defined] inv = ax.transAxes.inverted() rot_kw: dict[str, Any] = ( {"rotation": 90, "rotation_mode": "anchor"} if direction == "y" else {} ) # ── place texts ────────────────────────────────────────── if direction == "x": p_lo: tuple[float, float] = (0.0, float(offset)) p_hi: tuple[float, float] = (1.0, float(offset)) p_lb: tuple[float, float] = (0.5, float(offset)) else: p_lo = (float(offset), 0.0) p_hi = (float(offset), 1.0) p_lb = (float(offset), 0.5) t_lo = ax.text( *p_lo, low, transform=ax.transAxes, fontsize=fontsize, fontweight=weight, color=color, ha="left", va="center", clip_on=False, **rot_kw, ) t_hi = ax.text( *p_hi, high, transform=ax.transAxes, fontsize=fontsize, fontweight=weight, color=color, ha="right", va="center", clip_on=False, **rot_kw, ) t_lb = ax.text( *p_lb, label, transform=ax.transAxes, fontsize=fontsize_label, fontweight=weight, color=color, ha="center", va="center", clip_on=False, **rot_kw, ) # ── measure extents in axes fraction ───────────────────── fig.canvas.draw() def _edges(t): bb = t.get_window_extent(renderer) return inv.transform([[bb.x0, bb.y0], [bb.x1, bb.y1]]) i = 0 if direction == "x" else 1 lo_end = _edges(t_lo)[1][i] hi_start = _edges(t_hi)[0][i] lb_lo = _edges(t_lb)[0][i] lb_hi = _edges(t_lb)[1][i] # ── draw arrows ────────────────────────────────────────── def _arrow(tip, tail): if direction == "x": ax.annotate( "", xy=(tip, offset), xytext=(tail, offset), xycoords="axes fraction", arrowprops=arrow_kw, annotation_clip=False, ) else: ax.annotate( "", xy=(offset, tip), xytext=(offset, tail), xycoords="axes fraction", arrowprops=arrow_kw, annotation_clip=False, ) _arrow(lo_end + pad, lb_lo - pad) _arrow(hi_start - pad, lb_hi + pad)