Source code for mplsoccer.text

"""mplsoccer's text artists.

The module contains ``CurvedText``, which draws text along a circular arc
and is used by the radar and pizza charts to render curved parameter labels.

CurvedText author: PGupta-Git (https://github.com/PGupta-Git)
"""

from dataclasses import dataclass
from typing import Literal
import warnings

import numpy as np
from matplotlib import cbook
from matplotlib.artist import Artist
from matplotlib.patches import PathPatch
from matplotlib.text import Text
from matplotlib.textpath import TextPath, text_to_path
from matplotlib.transforms import Affine2D, Bbox

from .utils import validate_ax

__all__ = ['CurvedText']


_Align = Literal["center", "start", "end"]
_Direction = Literal["auto", "clockwise", "counterclockwise"]
_RadialAnchor = Literal["inner", "center"]


@dataclass
class _LineGlyphs:
    text: str
    chars: list[str]
    artists: list[PathPatch | None]  # None for whitespace characters
    advances_pt: list[float]  # character advance widths in points


[docs] class CurvedText(Artist): """Draw text along a circular arc for mplsoccer radar charts. Parameters ---------- ax : matplotlib axis The axis to plot on. Both Cartesian and polar axes are supported. x, y : float The position to place the text in data coordinates, with the same meaning as matplotlib's ``Axes.text``. On a Cartesian axis, the text curves along the circle through ``(x, y)`` around ``center``, with the line of text vertically centered on the circle; the position must differ from ``center``. On a polar axis (e.g. a pizza chart), ``x`` is the angle theta in radians and ``y`` is the radius, and the text curves along the circle of that radius around the polar origin. s : str The text to draw. Newlines split the text into multiple arcs stacked radially (see ``radial_anchor``). center : tuple of float, optional The point the text curves around, in data coordinates. Defaults to (0, 0). Only valid on Cartesian axes: on polar axes the text always curves around the polar origin, and passing ``center`` raises a ValueError. align : {'center', 'start', 'end'}, default 'center' How to align the text relative to ``(x, y)``. ``'start'`` and ``'end'`` refer to the text's reading direction along the arc rather than left/right, because ``direction='auto'`` can flip the direction the text flows. direction : {'auto', 'clockwise', 'counterclockwise'}, default 'auto' Direction to lay out characters along the arc. ``'auto'`` flips direction in the lower half of the circle so the text stays readable. radial_anchor : {'inner', 'center'}, default 'inner' How multiline text stacks relative to the circle through ``(x, y)``: ``'inner'`` centers the innermost line on the circle with further lines stacking outward, while ``'center'`` centers the whole block of lines on the circle. Single-line text is centered on the circle either way. letter_spacing : float, default 0 Additional spacing between characters in points, added on top of the font's natural character widths. The default of 0 uses the font's normal spacing; negative values tighten it. **kwargs : All other keyword arguments are passed on to matplotlib.axes.Axes.text. Arguments that do not translate to per-glyph curved layout (e.g. ``rotation`` and the alignment arguments) are ignored with a warning. Notes ----- - Mathtext/TeX rendering is disabled for curved text (it is non-trivial to support when placing per-character glyphs along an arc). - The layout mixes three unit systems, named by variable suffix: ``_data`` (data coordinates, where the arc is defined), ``_px`` (display pixels, where text sizes are known) and ``_pt`` (points, 1/72 inch, where font metrics are known). Each character's angular step along the arc is its pixel width divided by the pixel radius (an angle is an arc length over a radius). - An 'advance' is a font's per-character pen movement: the width a character occupies, including the small side bearings around its ink. Glyphs are spaced by their advances, like normal text. - The data-to-pixel scale is measured from the axes transform on every draw rather than assumed, so unequal aspect ratios, inverted axes and figure resizes are handled. - On polar axes the arc geometry (theta offset, theta direction and the origin radius) is also read from the axes on every draw, so the text stays correct if the axes are reconfigured after it is created. """ def __init__( self, ax, x: float, y: float, s: str, *, center: tuple[float, float] | None = None, align: _Align = "center", direction: _Direction = "auto", radial_anchor: _RadialAnchor = "inner", letter_spacing: float = 0.0, **kwargs, ): validate_ax(ax) super().__init__() # Text kwargs that do not translate to per-glyph curved layout are # accepted but ignored, with a warning (like the 'colors' argument # of the lines method). unsupported = ("rotation", "rotation_mode", "ha", "horizontalalignment", "va", "verticalalignment", "transform", "bbox", "backgroundcolor", "parse_math", "usetex") ignored = [key for key in unsupported if key in kwargs] if ignored: warnings.warn( f"curved text ignores the argument(s): {ignored}", UserWarning, stacklevel=2, ) self.axes = ax self.set_figure(ax.figure) # On polar axes (x, y) means (theta, radius) like matplotlib's # ax.text, and the text always curves around the polar origin. self._polar = ax.name == "polar" if self._polar: if center is not None: raise ValueError( "center is not supported on polar axes: the text always " "curves around the polar origin. Position the text with " "x (theta in radians) and y (radius) instead." ) self._center = None self._theta_data = float(x) self._radius = float(y) else: center = (0.0, 0.0) if center is None else center self._center = (float(center[0]), float(center[1])) delta_x = float(x) - self._center[0] delta_y = float(y) - self._center[1] self._radius = float(np.hypot(delta_x, delta_y)) if self._radius == 0: raise ValueError( f"The text position ({x}, {y}) must differ from the center " f"{center} so it defines the circle the text curves along." ) # The angle convention matches mplsoccer's radar charts: theta=0 at # the top, increasing clockwise, so x = r * sin(theta), # y = r * cos(theta). self._theta = float(np.arctan2(delta_x, delta_y)) self._align: _Align = align self._direction: _Direction = direction self._radial_anchor: _RadialAnchor = radial_anchor self._letter_spacing_points = float(letter_spacing) self._text = "" if s is None else str(s) self._validate_no_mathtext() self._text_kwargs = {key: value for key, value in kwargs.items() if key not in unsupported} # Match Matplotlib Text default zorder (3) so curved labels layer similarly. zorder = self._text_kwargs.pop("zorder", 3) self.set_zorder(zorder) # prevents clipping labels outside the axes with savefig(bbox_inches='tight') if not self._text_kwargs.get("clip_on"): self.set_clip_on(False) # A hidden, never-drawn Text that resolves the kwargs (rcParams # defaults, named fontsizes, validation); the glyphs copy its styling. self._template = Text(0, 0, "", **self._text_kwargs) self._template.set_parse_math(False) self._template.set_usetex(False) self._template.set_figure(ax.figure) self._template.axes = ax self._lines: list[_LineGlyphs] = [] self._glyphs_stale = False self._rebuild() def __repr__(self) -> str: if self._polar: x, y = self._theta_data, self._radius else: x = self._center[0] + self._radius * np.sin(self._theta) y = self._center[1] + self._radius * np.cos(self._theta) return f"CurvedText({x:g}, {y:g}, {self._text!r})" def _validate_no_mathtext(self) -> None: """ Curved text is laid out one glyph at a time along the arc, which cannot represent mathtext's two-dimensional layout (superscripts, fractions), so fail loudly rather than draw the markup literally. """ for line in self._text.splitlines(): if cbook.is_math_text(line): raise NotImplementedError( f"mathtext is not implemented for curved text: {line!r}. " "Use plain text, or escape dollar signs as \\$ " "for a literal '$'." )
[docs] def set_text(self, text: str) -> None: """Set the text string.""" self._text = "" if text is None else str(text) self._validate_no_mathtext() self._glyphs_stale = True self.stale = True
[docs] def get_text(self) -> str: """Return the text string.""" return self._text
# Common Text setters, so labels returned by draw_param_labels can be # restyled the same way whether or not they are curved. Each routes # through the template Text and marks the glyph patches for rebuild, # as their styling is otherwise fixed at construction.
[docs] def set_color(self, color) -> None: """Set the text color.""" self._template.set_color(color) self._glyphs_stale = True self.stale = True
[docs] def get_color(self): """Return the text color.""" return self._template.get_color()
[docs] def set_fontsize(self, fontsize) -> None: """Set the font size in points.""" self._template.set_fontsize(fontsize) self._glyphs_stale = True self.stale = True
[docs] def get_fontsize(self): """Return the font size in points.""" return self._template.get_fontsize()
[docs] def set_alpha(self, alpha) -> None: """Set the transparency, from 0 (invisible) to 1 (opaque).""" super().set_alpha(alpha) self._template.set_alpha(alpha) self._glyphs_stale = True
[docs] def get_alpha(self): """Return the transparency.""" return self._template.get_alpha()
[docs] def get_children(self): """Return the glyph patches, rebuilding them first if stale.""" if self._glyphs_stale: self._rebuild() children: list[Artist] = [] for line in self._lines: for artist in line.artists: if artist is not None: children.append(artist) return children
def _rebuild(self) -> None: """Recreate the glyph patches (one PathPatch per visible character). Decides what to draw, not where: each glyph outline is created at the origin with no transform, alongside the advance widths needed to place it; ``_layout`` positions the glyphs each time the artist is rendered. Each patch's styling is fixed when it is created, which is why restyling schedules a rebuild (``_glyphs_stale``) instead of updating the patches in place. """ self._glyphs_stale = False self._lines.clear() fontsize_points = float(self._template.get_fontsize()) prop = self._template.get_fontproperties() # unescape \$ like matplotlib's non-math text path does, so the # escape recommended by the mathtext error renders as a plain '$' text = self._text.replace(r"\$", "$") lines = text.splitlines() or [""] for line in lines: chars = list(line) artists: list[PathPatch | None] = [] advances_pt = [] for ch in chars: # Measure each character's advance width individually # rather than from cumulative text widths. Cumulative # widths include kerning adjustments that don't apply when # each character is rendered individually, causing spacing # mismatches. width, _, _ = text_to_path.get_text_width_height_descent( ch, prop, ismath=False ) advances_pt.append(float(width)) if ch.isspace(): artists.append(None) continue glyph_path = TextPath( (0, 0), ch, size=fontsize_points, prop=prop, usetex=False ) # Copy the template's styling and the common Artist # properties that users may set via text kwargs. Only this # closed list reaches the patches; text-only kwargs (e.g. # fontstyle) stay on the template. patch = PathPatch( glyph_path, facecolor=self._template.get_color(), edgecolor="none", linewidth=0, antialiased=self._template.get_antialiased(), alpha=self._template.get_alpha(), path_effects=self._template.get_path_effects(), zorder=self.get_zorder(), clip_on=self._template.get_clip_on(), clip_box=self._template.get_clip_box(), clip_path=self._template.get_clip_path(), url=self._template.get_url(), ) patch.set_figure(self.figure) patch.axes = self.axes artists.append(patch) self._lines.append(_LineGlyphs(text=line, chars=chars, artists=artists, advances_pt=advances_pt)) def _anchor_theta(self) -> float: """The anchor angle in screen terms: 0 at the top of the circle, increasing clockwise. On polar axes this is derived from the axes' theta offset and direction on every call, so it stays correct if they change after the text is created.""" if self._polar: return float(np.pi / 2 - (self.axes.get_theta_offset() + self.axes.get_theta_direction() * self._theta_data)) return self._theta def _arc_xy(self, theta: float, radius: float) -> tuple[float, float]: """Data coordinates of the point at screen angle ``theta`` (0 at the top of the circle, increasing clockwise) and ``radius`` from the arc center. This is the inverse of ``_anchor_theta``'s convention: on polar axes the screen angle is mapped back to a data theta, on Cartesian axes the point is measured out from ``center``.""" if self._polar: direction = self.axes.get_theta_direction() offset = self.axes.get_theta_offset() return ((np.pi / 2 - theta - offset) / direction, radius) return (self._center[0] + radius * float(np.sin(theta)), self._center[1] + radius * float(np.cos(theta))) def _center_px(self): """The pixel position of the point the text curves around.""" if self._polar: # the point at the origin radius maps to the screen center of # the polar disc (the theta value is irrelevant there) return self.axes.transData.transform((0.0, self.axes.get_rorigin())) return self.axes.transData.transform(self._center) def _direction_sign(self) -> int: """The sign of the angular step per character: 1 for clockwise, -1 for counterclockwise ('auto' flips in the lower half for readability).""" if self._direction == "clockwise": return 1 if self._direction == "counterclockwise": return -1 # the tolerance keeps text exactly at the left/right of the circle # (cos(theta) == 0) on the unflipped branch rather than letting # floating-point rounding decide return -1 if np.cos(self._anchor_theta()) < -1e-9 else 1 def _hide_glyphs(self, lines) -> None: """Hide glyphs whose positions cannot be computed. The layout skips a line (or the whole text) when the pixel radius or radial scale is not positive and finite (e.g. a NaN position, or a zero-size figure). ``draw`` renders every glyph with whatever transform it has, so skipped glyphs must be switched off or they would render at a stale or identity transform as stray marks at the figure origin. The next successful layout makes them visible again. """ for line in lines: for artist in line.artists: if artist is not None: artist.set_visible(False) def _layout_line( self, line: _LineGlyphs, radius_data: float, anchor_theta: float, direction_sign: int, ) -> None: """Decide where to draw: position one line's glyphs along the arc of ``radius_data`` (the baseline radius) from ``anchor_theta`` in the ``direction_sign`` direction, by setting each glyph's display transform.""" if not line.chars: return # The arc radius in pixels: the distance between the transformed # center and a transformed point on the arc. Pixel widths divided # by this radius give the angular steps along the arc. assert self.axes is not None center_px = self._center_px() anchor_px = self.axes.transData.transform( self._arc_xy(anchor_theta, radius_data)) radius_px = float(np.hypot(*(anchor_px - center_px))) if not np.isfinite(radius_px) or radius_px <= 0: self._hide_glyphs([line]) return pt_to_px = self.figure.dpi / 72.0 advances_px = [width * pt_to_px for width in line.advances_pt] letter_spacing_px = self._letter_spacing_points * pt_to_px total_width_px = (sum(advances_px) + letter_spacing_px * max(0, len(advances_px) - 1)) # Where the text begins reading: slide back from the anchor angle # so the text is centered on it ('center') or ends at it ('end'). start_theta = anchor_theta if self._align == "center": start_theta -= direction_sign * total_width_px / (2 * radius_px) elif self._align == "end": start_theta -= direction_sign * total_width_px / radius_px # March along the arc, placing each glyph at the center of its # advance width, like a pen advancing after each character. arc_offset_px = 0.0 for idx, (advance_pt, advance_px, artist) in enumerate( zip(line.advances_pt, advances_px, line.artists) ): glyph_center_px = arc_offset_px + (advance_px / 2) glyph_theta = start_theta + direction_sign * (glyph_center_px / radius_px) glyph_xy = self._arc_xy(glyph_theta, radius_data) if artist is not None: rotation = -np.rad2deg(glyph_theta) if direction_sign == -1: rotation += 180 glyph_px = self.axes.transData.transform(glyph_xy) # Anchor each glyph by the centre of its advance box -- the # same quantity used to place it along the arc. Anchoring by # the ink bounding-box centre instead shifts every glyph by # its side-bearing asymmetry (up to ~5% of the em for e.g. # 'r'), which makes the letter spacing visibly wobble. transform = ( Affine2D() # half the advance leftward, staying on the baseline .translate(-advance_pt / 2.0, 0.0) .scale(pt_to_px) .rotate_deg(rotation) .translate(glyph_px[0], glyph_px[1]) ) artist.set_transform(transform) artist.set_visible(True) # Stash the data-space anchor and rotation for tests/debugging. artist._mplsoccer_position = glyph_xy artist._mplsoccer_rotation = float(rotation) arc_offset_px += advance_px if idx < (len(advances_px) - 1): arc_offset_px += letter_spacing_px def _radial_pixel_scale(self) -> tuple[float, bool]: """Measure how data units map to pixels at the text's position. Takes the anchor point and a second point a tiny step further from the center, transforms both to pixels and compares them: the pixel distance divided by the step gives ``px_per_data_radial`` (how many pixels one data unit spans, moving away from the center), and ``flip_line_order`` is True when the second point lands lower on the screen, meaning wrapped lines must be stacked in reverse to read top-to-bottom (the lower half of the circle, or an inverted axis). Measuring instead of assuming handles unequal aspect ratios, inverted axes and nonlinear scales. """ anchor_theta = self._anchor_theta() nudge = max(1e-6, abs(self._radius) * 1e-3) anchor = self._arc_xy(anchor_theta, self._radius) nudged = self._arc_xy(anchor_theta, self._radius + nudge) anchor_px = self.axes.transData.transform(anchor) nudged_px = self.axes.transData.transform(nudged) flip_line_order = bool(nudged_px[1] < anchor_px[1]) px_per_data_radial = float(np.hypot(*(nudged_px - anchor_px))) / nudge return px_per_data_radial, flip_line_order def _line_stack_offset(self, line_idx: int, num_lines: int, flip_line_order: bool) -> float: """Which multiple of the line spacing this line sits at radially. Chosen so wrapped lines read top-to-bottom on the screen like normal multiline text: with ``radial_anchor='inner'`` the block grows away from the given radius, with ``'center'`` it is centered on it. ``flip_line_order`` reverses the stacking where farther from the center means lower on the screen. """ if self._radial_anchor == "center": half = (num_lines - 1) / 2 return (line_idx - half) if flip_line_order else (half - line_idx) if flip_line_order: return float(line_idx) # the first line lands on the circle return float((num_lines - 1) - line_idx) # the last line lands on the circle def _center_to_baseline_data(self, line: _LineGlyphs, px_per_data_radial: float) -> float: """Radial offset (in data units) from a line's center to its baseline. Glyph paths are anchored on the text baseline, but the caller's radius is treated as the line's visual centerline. The offset is half the line height minus the descent, measured against an 'lp' reference (full ascender plus descender) so lines without tall or deep characters get the same height as lines with them. """ prop = self._template.get_fontproperties() _, ref_height, ref_descent = text_to_path.get_text_width_height_descent( "lp", prop, ismath=False ) _, height, descent = text_to_path.get_text_width_height_descent( line.text if line.text else "lp", prop, ismath=False ) height = float(max(height, ref_height)) descent = float(max(descent, ref_descent)) center_to_baseline_pt = (height / 2.0) - descent center_to_baseline_px = center_to_baseline_pt * self.figure.dpi / 72.0 return center_to_baseline_px / px_per_data_radial def _layout(self) -> None: """Set display-space transforms on every glyph patch (no drawing).""" # rebuild first: laying out stale glyphs and rebuilding afterwards # would leave the fresh patches without transforms, drawing them # untransformed at the figure origin if self._glyphs_stale: self._rebuild() direction_sign = self._direction_sign() px_per_data_radial, flip_line_order = self._radial_pixel_scale() if not np.isfinite(px_per_data_radial) or px_per_data_radial <= 0: self._hide_glyphs(self._lines) return fontsize_points = float(self._template.get_fontsize()) linespacing = self._template.get_linespacing() if linespacing == "normal": # approximate the font-derived 'normal' of newer matplotlib # with its classic default: 1.2 multiples of the fontsize linespacing = 1.2 line_spacing_px = fontsize_points * float(linespacing) * self.figure.dpi / 72.0 line_spacing_data = line_spacing_px / px_per_data_radial anchor_theta = self._anchor_theta() num_lines = len(self._lines) for line_idx, line in enumerate(self._lines): stack_offset = self._line_stack_offset(line_idx, num_lines, flip_line_order) center_radius = self._radius + stack_offset * line_spacing_data baseline_radius = center_radius - direction_sign * ( self._center_to_baseline_data(line, px_per_data_radial) ) self._layout_line( line=line, radius_data=baseline_radius, anchor_theta=anchor_theta, direction_sign=direction_sign, )
[docs] def draw(self, renderer) -> None: """Position the glyphs for the current axes transform, then draw them.""" if not self.get_visible(): return if self.axes is None or self.figure is None: return self._layout() for artist in self.get_children(): artist.draw(renderer)
[docs] def get_window_extent(self, renderer=None): """Return the bounding box of the laid-out glyphs in display space. Implementing this lets curved labels participate in tight bounding box calculations (``savefig(bbox_inches='tight')``, ``tight_layout``, ``constrained_layout``) instead of reporting a zero-size box, which would crop the labels out of saved figures. """ if self.axes is None or self.figure is None: return Bbox.null() self._layout() extents = [artist.get_window_extent(renderer) for artist in self.get_children()] if not extents: return Bbox.null() return Bbox.union(extents)
[docs] def contains(self, mouseevent): """Return whether a mouse click or hover is over any of the glyphs, so curved labels respond to interactive events like regular text.""" if (self.axes is None or self.figure is None or mouseevent.canvas is not self.figure.canvas): return False, {} self._layout() inside = any(artist.contains(mouseevent)[0] for artist in self.get_children()) return inside, {}