Source code for ultraplot.textalign

#!/usr/bin/env python3
"""
Automatic repositioning of text and annotation boxes so they do not overlap.

The solver works entirely in display (pixel) space, so it is agnostic to the
transform attached to each label -- data, axes, log-scaled, polar and
geographic labels are all handled the same way. Labels are reset to their
original anchors before every pass, which keeps the result stable across
repeated draws, resizes and dpi changes.
"""

from __future__ import annotations

from typing import Iterable, Optional, Sequence

import matplotlib.collections as mcollections
import matplotlib.lines as mlines
import matplotlib.patches as mpatches
import matplotlib.text as mtext
import matplotlib.transforms as mtransforms
import numpy as np

__all__ = ["align_text"]

#: Above this many labels, fall back to a single relaxation schedule (see below).
_RESTART_LIMIT = 120


def _points_to_pixels(fig, value) -> float:
    return float(value) * fig.dpi / 72.0


def _expand_bbox(bbox, padx: float, pady: float):
    return mtransforms.Bbox.from_extents(
        bbox.x0 - padx, bbox.y0 - pady, bbox.x1 + padx, bbox.y1 + pady
    )


def _fits(box, sx, sy, bounds) -> bool:
    """
    Whether shifting ``box`` (x0, y0, x1, y1) by half of (sx, sy) keeps it inside
    ``bounds`` -- half, because a box only takes half of a pairwise push.
    """
    if bounds is None:
        return True
    dx, dy = 0.5 * sx, 0.5 * sy
    return (
        box[0] + dx >= bounds[0] - 1e-6
        and box[2] + dx <= bounds[2] + 1e-6
        and box[1] + dy >= bounds[1] - 1e-6
        and box[3] + dy <= bounds[3] + 1e-6
    )


def _overlap_shift(b1, b2, bounds=None):
    """
    Return the translation ``(sx, sy)`` separating box ``b1`` from ``b2``, or zeros.

    Boxes are ``(x0, y0, x1, y1)``. Both axis-aligned escape routes are considered
    and the shallower one wins, because every pixel a label moves is a pixel further
    from the thing it describes. If that route would push the label out of
    ``bounds`` the other one is used instead -- without this, a stack of labels
    jammed against the edge of the axes gets shoved straight back into the wall on
    every iteration and can never spread out sideways.

    This runs once per colliding pair per iteration, so it deals in plain floats:
    building a two-element numpy array here costs more than all the arithmetic.
    """
    dx = min(b1[2], b2[2]) - max(b1[0], b2[0])
    if dx <= 0:
        return 0.0, 0.0
    dy = min(b1[3], b2[3]) - max(b1[1], b2[1])
    if dy <= 0:
        return 0.0, 0.0
    sx = dx if (b1[0] + b1[2]) >= (b2[0] + b2[2]) else -dx
    sy = dy if (b1[1] + b1[3]) >= (b2[1] + b2[3]) else -dy
    if dx < dy:
        first, second = (sx, 0.0), (0.0, sy)
    else:
        first, second = (0.0, sy), (sx, 0.0)
    if _fits(b1, first[0], first[1], bounds) or not _fits(
        b1, second[0], second[1], bounds
    ):
        return first
    return second


def _point_shift(box, px, py):
    """
    Return the shortest translation ``(sx, sy)`` that moves ``box`` off a point.
    """
    left = px - box[0]
    right = box[2] - px
    down = py - box[1]
    up = box[3] - py
    best = min(left, right, down, up)
    if best == left:
        return left, 0.0
    if best == right:
        return -right, 0.0
    if best == down:
        return 0.0, down
    return 0.0, -up


def _colliding_pairs(boxes, live):
    """
    Indices (i, j), i < j, of every pair of live boxes that currently overlap.

    One vectorised sweep. A k-d tree radius query is asymptotically better and is
    genuinely faster at this step in isolation, but it does not pay for itself
    here: even at 800 labels this sweep is a low single-digit percentage of the
    solve, which is dominated by resolving the collisions it finds. It is not worth
    a SciPy dependency to speed up something that is not the bottleneck.
    """
    x0, y0, x1, y1 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
    hit = (
        (x0[:, None] < x1[None, :])
        & (x0[None, :] < x1[:, None])
        & (y0[:, None] < y1[None, :])
        & (y0[None, :] < y1[:, None])
    )
    hit &= live[:, None] & live[None, :]
    return np.argwhere(np.triu(hit, k=1))


def _count_overlaps(boxes) -> int:
    """
    Number of label pairs that visibly overlap. Takes the raw boxes, without the
    padding cushion: the cushion is a solver knob, but the score must be judged on
    the boxes the reader actually sees.
    """
    if len(boxes) < 2:
        return 0
    x0, y0, x1, y1 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
    inter_x = np.minimum(x1[:, None], x1[None, :]) - np.maximum(
        x0[:, None], x0[None, :]
    )
    inter_y = np.minimum(y1[:, None], y1[None, :]) - np.maximum(
        y0[:, None], y0[None, :]
    )
    hits = (inter_x > 0) & (inter_y > 0)
    return int(np.count_nonzero(np.triu(hits, k=1)))


def _crowd_seed(anchors, obstacles, sizes) -> np.ndarray:
    """
    Initial offsets that push every label away from its local crowd.

    Relaxing from the original positions is a purely local search, so a dense
    cluster can settle into a knot that no amount of further iteration undoes.
    Starting from a pre-exploded configuration puts the solver in a different
    basin, which is what the restarts are for.
    """
    crowd = anchors if not len(obstacles) else np.vstack([anchors, obstacles])
    seed = np.zeros_like(anchors)
    for i, point in enumerate(anchors):
        delta = point - crowd  # vectors pointing away from each neighbour
        dist = np.hypot(delta[:, 0], delta[:, 1])
        near = (dist > 1e-9) & (dist < 3 * sizes[i])
        if not np.any(near):
            continue
        # Weight nearer neighbours more heavily, then step out along the escape
        direction = np.sum(delta[near] / dist[near, None] ** 2, axis=0)
        norm = np.hypot(*direction)
        if norm > 1e-9:
            seed[i] = direction / norm * sizes[i]
    return seed


def _artist_points(artist) -> Optional[np.ndarray]:
    """
    Sample the display-space points an artist occupies, or None if unsupported.
    """
    if isinstance(artist, mlines.Line2D):
        data = artist.get_xydata()
        if data is None or len(data) == 0:
            return None
        return artist.get_transform().transform(np.asarray(data, dtype=float))
    if isinstance(artist, mcollections.PathCollection):
        offsets = artist.get_offsets()
        if offsets is None or len(offsets) == 0:
            return None
        return artist.get_offset_transform().transform(np.asarray(offsets, dtype=float))
    if isinstance(artist, mcollections.LineCollection):
        segs = artist.get_segments()
        if not segs:
            return None
        pts = np.concatenate([np.asarray(s, dtype=float) for s in segs])
        return artist.get_transform().transform(pts)
    return None


def _gather_obstacles(ax, labels, avoid_points: bool):
    """
    Collect display-space points (data markers/vertices) that labels avoid.
    """
    if not avoid_points:
        return np.empty((0, 2))
    points = []
    for artist in (*ax.lines, *ax.collections):
        if not artist.get_visible() or artist in labels:
            continue
        pts = _artist_points(artist)
        if pts is not None and len(pts):
            points.append(pts)
    if not points:
        return np.empty((0, 2))
    points = np.concatenate(points)
    finite = np.all(np.isfinite(points), axis=1)
    return points[finite]


def _label_bbox(label, renderer, padx, pady):
    try:
        if isinstance(label, mtext.Annotation):
            # Annotation.get_window_extent() unions the text with its arrow, and
            # the arrow only grows as the label moves away. Measure the box alone.
            label._renderer = renderer
            label.update_positions(renderer)
            bbox = mtext.Text.get_window_extent(label)
        else:
            bbox = label.get_window_extent(renderer=renderer)
    except Exception:
        return None
    if not np.all(np.isfinite(bbox.get_points())):
        return None
    return _expand_bbox(bbox, padx, pady)


def _position(label) -> tuple:
    """
    Where the label currently sits, in its own coordinate system.
    """
    if isinstance(label, mtext.Annotation):
        return tuple(map(float, label.xyann))
    return tuple(map(float, label.get_position()))


def _place(label, point) -> None:
    """
    Move a label to ``point`` and record that we are the ones who put it there.
    """
    if isinstance(label, mtext.Annotation):
        label.xyann = tuple(point)
    else:
        label.set_position(tuple(point))
    label._uplt_align_placed = _position(label)


def _reset_label(label) -> np.ndarray:
    """
    Restore a label to its user-specified anchor and return that anchor.

    The anchor is cached in the label's own coordinate system the first time we
    see it, so re-running the solver on every draw is idempotent rather than
    cumulative. A label that is not where we last left it has been repositioned by
    the user since the previous solve, and that new position becomes the anchor --
    otherwise ``set_position`` on an aligned label would appear to do nothing,
    with the next draw quietly dragging it back to an anchor the user has
    abandoned.
    """
    current = _position(label)
    if current != getattr(label, "_uplt_align_placed", None):
        label._uplt_align_anchor = current
    anchor = label._uplt_align_anchor
    _place(label, anchor)
    return np.asarray(anchor, dtype=float)


def _text_transform(label, renderer):
    """
    Return the transform mapping a label's stored position to display space.
    """
    if isinstance(label, mtext.Annotation):
        return label._get_xy_transform(renderer, label.anncoords)
    return label.get_transform()


def _move_label(label, delta_display, anchor, transform) -> None:
    """
    Offset a label by ``delta_display`` pixels from its anchor.
    """
    if not np.any(delta_display):
        return
    anchor_display = transform.transform(anchor)
    target = anchor_display + delta_display
    try:
        new = transform.inverted().transform(target)
    except Exception:
        return
    if not np.all(np.isfinite(new)):
        return
    _place(label, new)


def _target_display(label, renderer):
    """
    Display-space point the label refers to (the annotated point, or its anchor).
    """
    if isinstance(label, mtext.Annotation):
        trans = label._get_xy_transform(renderer, label.xycoords)
        return np.asarray(trans.transform(label.xy), dtype=float)
    return np.asarray(
        label.get_transform().transform(label._uplt_align_anchor), dtype=float
    )


[docs] def align_text( ax, labels: Optional[Sequence[mtext.Text]] = None, *, renderer=None, pad: float = 2.0, avoid_points: bool = True, avoid: Iterable = (), only_move: str = "xy", max_iter: int = 60, spring: float = 0.05, step: float = 0.6, clip: bool = True, arrows: bool | dict = False, min_arrow_dist: float = 8.0, ) -> list: """ Nudge text objects until they no longer overlap each other or the data. Parameters ---------- ax : ultraplot.axes.Axes The axes whose labels are aligned. labels : sequence of `~matplotlib.text.Text`, optional The labels to move. Default is every text registered for alignment on the axes (see `~ultraplot.axes.Axes.text` with ``avoid_overlap=True``). pad : float, default: 2.0 Padding in points added around every label bounding box. avoid_points : bool, default: True Whether labels also repel the data points of lines and scatter plots. avoid : sequence of `~matplotlib.artist.Artist`, optional Additional artists (a legend, an inset, ...) whose bounding boxes the labels must stay clear of. only_move : {'xy', 'x', 'y'}, default: 'xy' Restrict movement to a single axis. Useful when the horizontal position of a label is meaningful, e.g. labels on a time series. max_iter : int, default: 60 Maximum number of relaxation iterations. spring : float, default: 0.05 Strength of the pull back towards the original anchor. Larger values keep labels closer to where they were placed at the cost of overlap. step : float, default: 0.6 Damping applied to each iteration's displacement. clip : bool, default: True Whether to keep labels inside the axes. arrows : bool or dict, default: False Whether to draw a connector from displaced labels back to their anchor. A dict is passed to `~matplotlib.patches.FancyArrowPatch`. min_arrow_dist : float, default: 8.0 Only draw connectors for labels displaced further than this (in points). Returns ------- list The connector patches that were drawn, if any. """ if only_move not in ("xy", "x", "y"): raise ValueError(f"Invalid only_move={only_move!r}. Choose 'xy', 'x' or 'y'.") mask = np.array([float(only_move != "y"), float(only_move != "x")]) fig = ax.figure if fig is None: return [] if renderer is None: renderer = fig._get_renderer() if labels is None: labels = list(getattr(ax, "_align_texts", ())) labels = [t for t in labels if t.get_visible() and t.get_text().strip()] if not labels: return [] padx = pady = _points_to_pixels(fig, pad) # Reset to anchors first so repeated draws converge to the same layout anchors = [_reset_label(t) for t in labels] transforms = [_text_transform(t, renderer) for t in labels] obstacles = _gather_obstacles(ax, labels, avoid_points) static_bboxes = [] for artist in avoid: try: static_bboxes.append(artist.get_window_extent(renderer=renderer)) except Exception: continue axes_bbox = ax.get_window_extent(renderer=renderer) if clip else None # Measure each label exactly once. Translating a text does not change the size # of its bounding box, so every later box is just this one plus an offset -- # which keeps the whole relaxation in numpy and off the renderer. raw = [] for label in labels: bbox = _label_bbox(label, renderer, 0, 0) raw.append( (np.nan, np.nan, np.nan, np.nan) if bbox is None else (bbox.x0, bbox.y0, bbox.x1, bbox.y1) ) raw = np.array(raw, dtype=float) live = np.all(np.isfinite(raw), axis=1) order = [i for i in range(len(labels)) if live[i]] statics = np.array( [(bb.x0, bb.y0, bb.x1, bb.y1) for bb in static_bboxes], dtype=float ).reshape(-1, 4) obs_x = obstacles[:, 0] if len(obstacles) else np.empty(0) obs_y = obstacles[:, 1] if len(obstacles) else np.empty(0) bounds = ( None if axes_bbox is None else np.array([axes_bbox.x0, axes_bbox.y0, axes_bbox.x1, axes_bbox.y1]) ) def _solve(step, spring, seed=None, cushion=1.0): """One relaxation run; returns (score, offsets).""" base = raw + cushion * np.array([-padx, -pady, padx, pady]) offsets = np.zeros((len(labels), 2)) if seed is None else seed * mask best_offsets = offsets.copy() best_score = (np.inf, np.inf, np.inf, np.inf) for it in range(max_iter): boxes = base + np.hstack([offsets, offsets]) # Score the layout that `offsets` actually produces, on the true boxes, # and before the sweep below starts mutating `boxes` in place. Scoring # afterwards would rank the provisional fully-resolved state while # saving the damped offsets that do not correspond to it. overlaps = _count_overlaps((raw + np.hstack([offsets, offsets]))[live]) # Resolve collisions one at a time, each push applied immediately so that # later pairs see the updated box (Gauss-Seidel). Accumulating all the # pushes and applying them together lets a label squeezed between two # neighbours take equal and opposite shifts that cancel, deadlocking the # stack instead of expanding it. # # This sweep is the hot loop of the whole module, so it runs on plain # Python floats. Indexing a row of a numpy array and adding a length-two # array to it costs several times the arithmetic it performs. work = boxes.tolist() fx = [0.0] * len(labels) fy = [0.0] * len(labels) px_ = [0.0] * len(labels) py_ = [0.0] * len(labels) def _push(i, sx, sy): if sx or sy: fx[i] += sx fy[i] += sy box = work[i] box[0] += sx box[1] += sy box[2] += sx box[3] += sy # Points and static artists cannot move, so settle the labels against # them first and give label-on-label collisions the last word: a label # shoved off a marker must not come to rest on top of a neighbour. for i in order: for k in range(len(statics)): _push(i, *_overlap_shift(work[i], statics[k], bounds)) if len(obstacles): b = work[i] inside = ( (obs_x > b[0]) & (obs_x < b[2]) & (obs_y > b[1]) & (obs_y < b[3]) ) for ox, oy in zip(obs_x[inside], obs_y[inside]): _push(i, *_point_shift(work[i], ox, oy)) # Look for label-on-label collisions only now, so the pairs reflect where # the pushes above actually left the labels. One vectorised sweep finds # them; the loop then resolves just those. Pairs that a push creates # mid-sweep are picked up on the next iteration. for i, j in _colliding_pairs(np.array(work), live): sx, sy = _overlap_shift(work[i], work[j], bounds) if not (sx or sy): continue # an earlier push in this sweep already separated them hx, hy = 0.5 * sx, 0.5 * sy px_[i] += hx py_[i] += hy px_[j] -= hx py_[j] -= hy _push(i, hx, hy) _push(j, -hx, -hy) forces = np.column_stack([fx, fy]) pair_forces = np.column_stack([px_, py_]) # Whatever is left over came from the markers and the `avoid` artists obstacle_forces = forces - pair_forces # Rank on the overlap count taken above: what the eye objects to first is # labels sitting on each other. Sitting on a marker is the next cost, and # it has to be scored -- a lone label parked on top of a marker overlaps # no other label, so without this term it would score a perfect zero on # the very first iteration and never move. Distance from the anchor only # breaks ties, so labels stay put when nothing is in their way. crowding = float(np.sum(np.hypot(pair_forces[:, 0], pair_forces[:, 1]))) blocked = float( np.sum(np.hypot(obstacle_forces[:, 0], obstacle_forces[:, 1])) ) displacement = float(np.sum(np.hypot(offsets[:, 0], offsets[:, 1]))) score = (overlaps, crowding, blocked, displacement) if score < best_score: best_score = score best_offsets = offsets.copy() if not forces.any(): break # Pull labels that are already free back towards their anchor. Springing # a colliding label would merely balance the repulsion and leave the # overlap in place. Fade the spring out so late iterations prioritise # separation over staying close to the anchor. free = ~np.any(forces, axis=1) forces[free] -= spring * (1 - it / max_iter) * offsets[free] forces *= mask offsets = offsets + step * forces if bounds is not None: boxes = base + np.hstack([offsets, offsets]) lo = bounds[:2] - boxes[:, :2] hi = bounds[2:] - boxes[:, 2:] nudge = np.clip(0.0, np.minimum(lo, hi), np.maximum(lo, hi)) offsets += np.where(live[:, None], nudge * mask, 0.0) return best_score, best_offsets # The solve is by far the most expensive part of a draw, and it is a pure # function of the geometry gathered above. A figure is redrawn far more often # than it changes -- the tight layout measures it, a legend is toggled, a # notebook cell re-renders it -- so remember the answer and only pay for it # again when one of the inputs actually moves. Every input is in display # space, so a resize, a dpi change or new data limits all invalidate this on # their own. The obstacles go in as a hash: a dense line contributes tens of # thousands of points, and the key must not pin a copy of them all in memory. key = ( tuple(map(id, labels)), raw.tobytes(), statics.tobytes(), None if bounds is None else bounds.tobytes(), hash(obstacles.tobytes()), padx, pady, only_move, max_iter, spring, step, ) cached = getattr(ax, "_align_cache", None) if cached is not None and cached[0] == key: offsets = cached[1] else: # No single relaxation schedule wins on every layout: a decisive step clears # dense stacks but can overshoot into a worse arrangement, a gentle one does # the reverse, and relaxing from the original positions cannot always escape # a knotted cluster at all. The solve is cheap now that nothing re-measures, # so run a handful and keep the best. The schedules are fixed rather than # random, so the same figure always lands on the same layout. centres = 0.5 * (raw[:, :2] + raw[:, 2:]) sizes = 0.5 * np.hypot(raw[:, 2] - raw[:, 0], raw[:, 3] - raw[:, 1]) seed = _crowd_seed(np.nan_to_num(centres), obstacles, np.nan_to_num(sizes)) schedules = [ (step, spring, None, 1.0), (1.0, spring, None, 1.0), (step, spring, seed, 1.0), (1.0, 0.0, seed, 1.0), (step, spring, None, 1.5), (step, spring, None, 0.5), (0.4, 0.5 * spring, None, 1.0), ] # Each restart costs a full relaxation. Past a few hundred labels they cannot # all fit anyway, so the restarts buy nothing but seconds of redraw time. if len(order) > _RESTART_LIMIT: schedules = schedules[:2] offsets = None best = (np.inf, np.inf, np.inf, np.inf) for step_i, spring_i, seed_i, cushion_i in schedules: score, candidate = _solve(step_i, spring_i, seed_i, cushion_i) if score < best: best, offsets = score, candidate if best[0] == 0 and best[2] == 0: break # nothing overlaps and nothing sits on a marker: we are done ax._align_cache = (key, offsets) # Apply the final offsets for label, anchor, trans, offset in zip(labels, anchors, transforms, offsets): _move_label(label, offset, anchor, trans) # Connectors from displaced labels back to what they describe patches = [] for patch in getattr(ax, "_align_arrows", ()): try: patch.remove() except Exception: pass ax._align_arrows = patches if not arrows: return patches props = dict(arrowstyle="-", color="0.5", linewidth=0.8, shrinkA=2, shrinkB=2) if isinstance(arrows, dict): props.update(arrows) threshold = _points_to_pixels(fig, min_arrow_dist) inv = ax.transData.inverted() for label, offset in zip(labels, offsets): if np.hypot(*offset) < threshold: continue if isinstance(label, mtext.Annotation) and label.arrowprops: continue # matplotlib already draws this one target = _target_display(label, renderer) bbox = _label_bbox(label, renderer, 0, 0) if bbox is None: continue center = np.array([0.5 * (bbox.x0 + bbox.x1), 0.5 * (bbox.y0 + bbox.y1)]) patch = mpatches.FancyArrowPatch( posA=inv.transform(center), posB=inv.transform(target), transform=ax.transData, zorder=label.get_zorder() - 0.1, **props, ) # add_artist, not add_patch: a connector is decoration, and letting it # widen the data limits would move the very labels it is drawn from, # changing the layout on every redraw. ax.add_artist(patch) patch.set_clip_path(ax.patch) patches.append(patch) return patches