2024 lines
69 KiB
Python
2024 lines
69 KiB
Python
"""
|
|
Classes for including text in a figure.
|
|
"""
|
|
|
|
import functools
|
|
import logging
|
|
import math
|
|
from numbers import Real
|
|
import weakref
|
|
|
|
import numpy as np
|
|
|
|
import matplotlib as mpl
|
|
from . import _api, artist, cbook, _docstring
|
|
from .artist import Artist
|
|
from .font_manager import FontProperties
|
|
from .patches import FancyArrowPatch, FancyBboxPatch, Rectangle
|
|
from .textpath import TextPath, TextToPath # noqa # Logically located here
|
|
from .transforms import (
|
|
Affine2D, Bbox, BboxBase, BboxTransformTo, IdentityTransform, Transform)
|
|
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_textbox(text, renderer):
|
|
"""
|
|
Calculate the bounding box of the text.
|
|
|
|
The bbox position takes text rotation into account, but the width and
|
|
height are those of the unrotated box (unlike `.Text.get_window_extent`).
|
|
"""
|
|
# TODO : This function may move into the Text class as a method. As a
|
|
# matter of fact, the information from the _get_textbox function
|
|
# should be available during the Text._get_layout() call, which is
|
|
# called within the _get_textbox. So, it would better to move this
|
|
# function as a method with some refactoring of _get_layout method.
|
|
|
|
projected_xs = []
|
|
projected_ys = []
|
|
|
|
theta = np.deg2rad(text.get_rotation())
|
|
tr = Affine2D().rotate(-theta)
|
|
|
|
_, parts, d = text._get_layout(renderer)
|
|
|
|
for t, wh, x, y in parts:
|
|
w, h = wh
|
|
|
|
xt1, yt1 = tr.transform((x, y))
|
|
yt1 -= d
|
|
xt2, yt2 = xt1 + w, yt1 + h
|
|
|
|
projected_xs.extend([xt1, xt2])
|
|
projected_ys.extend([yt1, yt2])
|
|
|
|
xt_box, yt_box = min(projected_xs), min(projected_ys)
|
|
w_box, h_box = max(projected_xs) - xt_box, max(projected_ys) - yt_box
|
|
|
|
x_box, y_box = Affine2D().rotate(theta).transform((xt_box, yt_box))
|
|
|
|
return x_box, y_box, w_box, h_box
|
|
|
|
|
|
def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi):
|
|
"""Call ``renderer.get_text_width_height_descent``, caching the results."""
|
|
# Cached based on a copy of fontprop so that later in-place mutations of
|
|
# the passed-in argument do not mess up the cache.
|
|
return _get_text_metrics_with_cache_impl(
|
|
weakref.ref(renderer), text, fontprop.copy(), ismath, dpi)
|
|
|
|
|
|
@functools.lru_cache(4096)
|
|
def _get_text_metrics_with_cache_impl(
|
|
renderer_ref, text, fontprop, ismath, dpi):
|
|
# dpi is unused, but participates in cache invalidation (via the renderer).
|
|
return renderer_ref().get_text_width_height_descent(text, fontprop, ismath)
|
|
|
|
|
|
@_docstring.interpd
|
|
@_api.define_aliases({
|
|
"color": ["c"],
|
|
"fontfamily": ["family"],
|
|
"fontproperties": ["font", "font_properties"],
|
|
"horizontalalignment": ["ha"],
|
|
"multialignment": ["ma"],
|
|
"fontname": ["name"],
|
|
"fontsize": ["size"],
|
|
"fontstretch": ["stretch"],
|
|
"fontstyle": ["style"],
|
|
"fontvariant": ["variant"],
|
|
"verticalalignment": ["va"],
|
|
"fontweight": ["weight"],
|
|
})
|
|
class Text(Artist):
|
|
"""Handle storing and drawing of text in window or data coordinates."""
|
|
|
|
zorder = 3
|
|
_charsize_cache = dict()
|
|
|
|
def __repr__(self):
|
|
return f"Text({self._x}, {self._y}, {self._text!r})"
|
|
|
|
def __init__(self,
|
|
x=0, y=0, text='', *,
|
|
color=None, # defaults to rc params
|
|
verticalalignment='baseline',
|
|
horizontalalignment='left',
|
|
multialignment=None,
|
|
fontproperties=None, # defaults to FontProperties()
|
|
rotation=None,
|
|
linespacing=None,
|
|
rotation_mode=None,
|
|
usetex=None, # defaults to rcParams['text.usetex']
|
|
wrap=False,
|
|
transform_rotates_text=False,
|
|
parse_math=None, # defaults to rcParams['text.parse_math']
|
|
antialiased=None, # defaults to rcParams['text.antialiased']
|
|
**kwargs
|
|
):
|
|
"""
|
|
Create a `.Text` instance at *x*, *y* with string *text*.
|
|
|
|
The text is aligned relative to the anchor point (*x*, *y*) according
|
|
to ``horizontalalignment`` (default: 'left') and ``verticalalignment``
|
|
(default: 'baseline'). See also
|
|
:doc:`/gallery/text_labels_and_annotations/text_alignment`.
|
|
|
|
While Text accepts the 'label' keyword argument, by default it is not
|
|
added to the handles of a legend.
|
|
|
|
Valid keyword arguments are:
|
|
|
|
%(Text:kwdoc)s
|
|
"""
|
|
super().__init__()
|
|
self._x, self._y = x, y
|
|
self._text = ''
|
|
self._reset_visual_defaults(
|
|
text=text,
|
|
color=color,
|
|
fontproperties=fontproperties,
|
|
usetex=usetex,
|
|
parse_math=parse_math,
|
|
wrap=wrap,
|
|
verticalalignment=verticalalignment,
|
|
horizontalalignment=horizontalalignment,
|
|
multialignment=multialignment,
|
|
rotation=rotation,
|
|
transform_rotates_text=transform_rotates_text,
|
|
linespacing=linespacing,
|
|
rotation_mode=rotation_mode,
|
|
antialiased=antialiased
|
|
)
|
|
self.update(kwargs)
|
|
|
|
def _reset_visual_defaults(
|
|
self,
|
|
text='',
|
|
color=None,
|
|
fontproperties=None,
|
|
usetex=None,
|
|
parse_math=None,
|
|
wrap=False,
|
|
verticalalignment='baseline',
|
|
horizontalalignment='left',
|
|
multialignment=None,
|
|
rotation=None,
|
|
transform_rotates_text=False,
|
|
linespacing=None,
|
|
rotation_mode=None,
|
|
antialiased=None
|
|
):
|
|
self.set_text(text)
|
|
self.set_color(mpl._val_or_rc(color, "text.color"))
|
|
self.set_fontproperties(fontproperties)
|
|
self.set_usetex(usetex)
|
|
self.set_parse_math(mpl._val_or_rc(parse_math, 'text.parse_math'))
|
|
self.set_wrap(wrap)
|
|
self.set_verticalalignment(verticalalignment)
|
|
self.set_horizontalalignment(horizontalalignment)
|
|
self._multialignment = multialignment
|
|
self.set_rotation(rotation)
|
|
self._transform_rotates_text = transform_rotates_text
|
|
self._bbox_patch = None # a FancyBboxPatch instance
|
|
self._renderer = None
|
|
if linespacing is None:
|
|
linespacing = 1.2 # Maybe use rcParam later.
|
|
self.set_linespacing(linespacing)
|
|
self.set_rotation_mode(rotation_mode)
|
|
self.set_antialiased(antialiased if antialiased is not None else
|
|
mpl.rcParams['text.antialiased'])
|
|
|
|
def update(self, kwargs):
|
|
# docstring inherited
|
|
ret = []
|
|
kwargs = cbook.normalize_kwargs(kwargs, Text)
|
|
sentinel = object() # bbox can be None, so use another sentinel.
|
|
# Update fontproperties first, as it has lowest priority.
|
|
fontproperties = kwargs.pop("fontproperties", sentinel)
|
|
if fontproperties is not sentinel:
|
|
ret.append(self.set_fontproperties(fontproperties))
|
|
# Update bbox last, as it depends on font properties.
|
|
bbox = kwargs.pop("bbox", sentinel)
|
|
ret.extend(super().update(kwargs))
|
|
if bbox is not sentinel:
|
|
ret.append(self.set_bbox(bbox))
|
|
return ret
|
|
|
|
def __getstate__(self):
|
|
d = super().__getstate__()
|
|
# remove the cached _renderer (if it exists)
|
|
d['_renderer'] = None
|
|
return d
|
|
|
|
def contains(self, mouseevent):
|
|
"""
|
|
Return whether the mouse event occurred inside the axis-aligned
|
|
bounding-box of the text.
|
|
"""
|
|
if (self._different_canvas(mouseevent) or not self.get_visible()
|
|
or self._renderer is None):
|
|
return False, {}
|
|
# Explicitly use Text.get_window_extent(self) and not
|
|
# self.get_window_extent() so that Annotation.contains does not
|
|
# accidentally cover the entire annotation bounding box.
|
|
bbox = Text.get_window_extent(self)
|
|
inside = (bbox.x0 <= mouseevent.x <= bbox.x1
|
|
and bbox.y0 <= mouseevent.y <= bbox.y1)
|
|
cattr = {}
|
|
# if the text has a surrounding patch, also check containment for it,
|
|
# and merge the results with the results for the text.
|
|
if self._bbox_patch:
|
|
patch_inside, patch_cattr = self._bbox_patch.contains(mouseevent)
|
|
inside = inside or patch_inside
|
|
cattr["bbox_patch"] = patch_cattr
|
|
return inside, cattr
|
|
|
|
def _get_xy_display(self):
|
|
"""
|
|
Get the (possibly unit converted) transformed x, y in display coords.
|
|
"""
|
|
x, y = self.get_unitless_position()
|
|
return self.get_transform().transform((x, y))
|
|
|
|
def _get_multialignment(self):
|
|
if self._multialignment is not None:
|
|
return self._multialignment
|
|
else:
|
|
return self._horizontalalignment
|
|
|
|
def _char_index_at(self, x):
|
|
"""
|
|
Calculate the index closest to the coordinate x in display space.
|
|
|
|
The position of text[index] is assumed to be the sum of the widths
|
|
of all preceding characters text[:index].
|
|
|
|
This works only on single line texts.
|
|
"""
|
|
if not self._text:
|
|
return 0
|
|
|
|
text = self._text
|
|
|
|
fontproperties = str(self._fontproperties)
|
|
if fontproperties not in Text._charsize_cache:
|
|
Text._charsize_cache[fontproperties] = dict()
|
|
|
|
charsize_cache = Text._charsize_cache[fontproperties]
|
|
for char in set(text):
|
|
if char not in charsize_cache:
|
|
self.set_text(char)
|
|
bb = self.get_window_extent()
|
|
charsize_cache[char] = bb.x1 - bb.x0
|
|
|
|
self.set_text(text)
|
|
bb = self.get_window_extent()
|
|
|
|
size_accum = np.cumsum([0] + [charsize_cache[x] for x in text])
|
|
std_x = x - bb.x0
|
|
return (np.abs(size_accum - std_x)).argmin()
|
|
|
|
def get_rotation(self):
|
|
"""Return the text angle in degrees between 0 and 360."""
|
|
if self.get_transform_rotates_text():
|
|
return self.get_transform().transform_angles(
|
|
[self._rotation], [self.get_unitless_position()]).item(0)
|
|
else:
|
|
return self._rotation
|
|
|
|
def get_transform_rotates_text(self):
|
|
"""
|
|
Return whether rotations of the transform affect the text direction.
|
|
"""
|
|
return self._transform_rotates_text
|
|
|
|
def set_rotation_mode(self, m):
|
|
"""
|
|
Set text rotation mode.
|
|
|
|
Parameters
|
|
----------
|
|
m : {None, 'default', 'anchor'}
|
|
If ``"default"``, the text will be first rotated, then aligned according
|
|
to their horizontal and vertical alignments. If ``"anchor"``, then
|
|
alignment occurs before rotation. Passing ``None`` will set the rotation
|
|
mode to ``"default"``.
|
|
"""
|
|
if m is None:
|
|
m = "default"
|
|
else:
|
|
_api.check_in_list(("anchor", "default"), rotation_mode=m)
|
|
self._rotation_mode = m
|
|
self.stale = True
|
|
|
|
def get_rotation_mode(self):
|
|
"""Return the text rotation mode."""
|
|
return self._rotation_mode
|
|
|
|
def set_antialiased(self, antialiased):
|
|
"""
|
|
Set whether to use antialiased rendering.
|
|
|
|
Parameters
|
|
----------
|
|
antialiased : bool
|
|
|
|
Notes
|
|
-----
|
|
Antialiasing will be determined by :rc:`text.antialiased`
|
|
and the parameter *antialiased* will have no effect if the text contains
|
|
math expressions.
|
|
"""
|
|
self._antialiased = antialiased
|
|
self.stale = True
|
|
|
|
def get_antialiased(self):
|
|
"""Return whether antialiased rendering is used."""
|
|
return self._antialiased
|
|
|
|
def update_from(self, other):
|
|
# docstring inherited
|
|
super().update_from(other)
|
|
self._color = other._color
|
|
self._multialignment = other._multialignment
|
|
self._verticalalignment = other._verticalalignment
|
|
self._horizontalalignment = other._horizontalalignment
|
|
self._fontproperties = other._fontproperties.copy()
|
|
self._usetex = other._usetex
|
|
self._rotation = other._rotation
|
|
self._transform_rotates_text = other._transform_rotates_text
|
|
self._picker = other._picker
|
|
self._linespacing = other._linespacing
|
|
self._antialiased = other._antialiased
|
|
self.stale = True
|
|
|
|
def _get_layout(self, renderer):
|
|
"""
|
|
Return the extent (bbox) of the text together with
|
|
multiple-alignment information. Note that it returns an extent
|
|
of a rotated text when necessary.
|
|
"""
|
|
thisx, thisy = 0.0, 0.0
|
|
lines = self._get_wrapped_text().split("\n") # Ensures lines is not empty.
|
|
|
|
ws = []
|
|
hs = []
|
|
xs = []
|
|
ys = []
|
|
|
|
# Full vertical extent of font, including ascenders and descenders:
|
|
_, lp_h, lp_d = _get_text_metrics_with_cache(
|
|
renderer, "lp", self._fontproperties,
|
|
ismath="TeX" if self.get_usetex() else False, dpi=self.figure.dpi)
|
|
min_dy = (lp_h - lp_d) * self._linespacing
|
|
|
|
for i, line in enumerate(lines):
|
|
clean_line, ismath = self._preprocess_math(line)
|
|
if clean_line:
|
|
w, h, d = _get_text_metrics_with_cache(
|
|
renderer, clean_line, self._fontproperties,
|
|
ismath=ismath, dpi=self.figure.dpi)
|
|
else:
|
|
w = h = d = 0
|
|
|
|
# For multiline text, increase the line spacing when the text
|
|
# net-height (excluding baseline) is larger than that of a "l"
|
|
# (e.g., use of superscripts), which seems what TeX does.
|
|
h = max(h, lp_h)
|
|
d = max(d, lp_d)
|
|
|
|
ws.append(w)
|
|
hs.append(h)
|
|
|
|
# Metrics of the last line that are needed later:
|
|
baseline = (h - d) - thisy
|
|
|
|
if i == 0:
|
|
# position at baseline
|
|
thisy = -(h - d)
|
|
else:
|
|
# put baseline a good distance from bottom of previous line
|
|
thisy -= max(min_dy, (h - d) * self._linespacing)
|
|
|
|
xs.append(thisx) # == 0.
|
|
ys.append(thisy)
|
|
|
|
thisy -= d
|
|
|
|
# Metrics of the last line that are needed later:
|
|
descent = d
|
|
|
|
# Bounding box definition:
|
|
width = max(ws)
|
|
xmin = 0
|
|
xmax = width
|
|
ymax = 0
|
|
ymin = ys[-1] - descent # baseline of last line minus its descent
|
|
|
|
# get the rotation matrix
|
|
M = Affine2D().rotate_deg(self.get_rotation())
|
|
|
|
# now offset the individual text lines within the box
|
|
malign = self._get_multialignment()
|
|
if malign == 'left':
|
|
offset_layout = [(x, y) for x, y in zip(xs, ys)]
|
|
elif malign == 'center':
|
|
offset_layout = [(x + width / 2 - w / 2, y)
|
|
for x, y, w in zip(xs, ys, ws)]
|
|
elif malign == 'right':
|
|
offset_layout = [(x + width - w, y)
|
|
for x, y, w in zip(xs, ys, ws)]
|
|
|
|
# the corners of the unrotated bounding box
|
|
corners_horiz = np.array(
|
|
[(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)])
|
|
|
|
# now rotate the bbox
|
|
corners_rotated = M.transform(corners_horiz)
|
|
# compute the bounds of the rotated box
|
|
xmin = corners_rotated[:, 0].min()
|
|
xmax = corners_rotated[:, 0].max()
|
|
ymin = corners_rotated[:, 1].min()
|
|
ymax = corners_rotated[:, 1].max()
|
|
width = xmax - xmin
|
|
height = ymax - ymin
|
|
|
|
# Now move the box to the target position offset the display
|
|
# bbox by alignment
|
|
halign = self._horizontalalignment
|
|
valign = self._verticalalignment
|
|
|
|
rotation_mode = self.get_rotation_mode()
|
|
if rotation_mode != "anchor":
|
|
# compute the text location in display coords and the offsets
|
|
# necessary to align the bbox with that location
|
|
if halign == 'center':
|
|
offsetx = (xmin + xmax) / 2
|
|
elif halign == 'right':
|
|
offsetx = xmax
|
|
else:
|
|
offsetx = xmin
|
|
|
|
if valign == 'center':
|
|
offsety = (ymin + ymax) / 2
|
|
elif valign == 'top':
|
|
offsety = ymax
|
|
elif valign == 'baseline':
|
|
offsety = ymin + descent
|
|
elif valign == 'center_baseline':
|
|
offsety = ymin + height - baseline / 2.0
|
|
else:
|
|
offsety = ymin
|
|
else:
|
|
xmin1, ymin1 = corners_horiz[0]
|
|
xmax1, ymax1 = corners_horiz[2]
|
|
|
|
if halign == 'center':
|
|
offsetx = (xmin1 + xmax1) / 2.0
|
|
elif halign == 'right':
|
|
offsetx = xmax1
|
|
else:
|
|
offsetx = xmin1
|
|
|
|
if valign == 'center':
|
|
offsety = (ymin1 + ymax1) / 2.0
|
|
elif valign == 'top':
|
|
offsety = ymax1
|
|
elif valign == 'baseline':
|
|
offsety = ymax1 - baseline
|
|
elif valign == 'center_baseline':
|
|
offsety = ymax1 - baseline / 2.0
|
|
else:
|
|
offsety = ymin1
|
|
|
|
offsetx, offsety = M.transform((offsetx, offsety))
|
|
|
|
xmin -= offsetx
|
|
ymin -= offsety
|
|
|
|
bbox = Bbox.from_bounds(xmin, ymin, width, height)
|
|
|
|
# now rotate the positions around the first (x, y) position
|
|
xys = M.transform(offset_layout) - (offsetx, offsety)
|
|
|
|
return bbox, list(zip(lines, zip(ws, hs), *xys.T)), descent
|
|
|
|
def set_bbox(self, rectprops):
|
|
"""
|
|
Draw a bounding box around self.
|
|
|
|
Parameters
|
|
----------
|
|
rectprops : dict with properties for `.patches.FancyBboxPatch`
|
|
The default boxstyle is 'square'. The mutation
|
|
scale of the `.patches.FancyBboxPatch` is set to the fontsize.
|
|
|
|
Examples
|
|
--------
|
|
::
|
|
|
|
t.set_bbox(dict(facecolor='red', alpha=0.5))
|
|
"""
|
|
|
|
if rectprops is not None:
|
|
props = rectprops.copy()
|
|
boxstyle = props.pop("boxstyle", None)
|
|
pad = props.pop("pad", None)
|
|
if boxstyle is None:
|
|
boxstyle = "square"
|
|
if pad is None:
|
|
pad = 4 # points
|
|
pad /= self.get_size() # to fraction of font size
|
|
else:
|
|
if pad is None:
|
|
pad = 0.3
|
|
# boxstyle could be a callable or a string
|
|
if isinstance(boxstyle, str) and "pad" not in boxstyle:
|
|
boxstyle += ",pad=%0.2f" % pad
|
|
self._bbox_patch = FancyBboxPatch(
|
|
(0, 0), 1, 1,
|
|
boxstyle=boxstyle, transform=IdentityTransform(), **props)
|
|
else:
|
|
self._bbox_patch = None
|
|
|
|
self._update_clip_properties()
|
|
|
|
def get_bbox_patch(self):
|
|
"""
|
|
Return the bbox Patch, or None if the `.patches.FancyBboxPatch`
|
|
is not made.
|
|
"""
|
|
return self._bbox_patch
|
|
|
|
def update_bbox_position_size(self, renderer):
|
|
"""
|
|
Update the location and the size of the bbox.
|
|
|
|
This method should be used when the position and size of the bbox needs
|
|
to be updated before actually drawing the bbox.
|
|
"""
|
|
if self._bbox_patch:
|
|
# don't use self.get_unitless_position here, which refers to text
|
|
# position in Text:
|
|
posx = float(self.convert_xunits(self._x))
|
|
posy = float(self.convert_yunits(self._y))
|
|
posx, posy = self.get_transform().transform((posx, posy))
|
|
|
|
x_box, y_box, w_box, h_box = _get_textbox(self, renderer)
|
|
self._bbox_patch.set_bounds(0., 0., w_box, h_box)
|
|
self._bbox_patch.set_transform(
|
|
Affine2D()
|
|
.rotate_deg(self.get_rotation())
|
|
.translate(posx + x_box, posy + y_box))
|
|
fontsize_in_pixel = renderer.points_to_pixels(self.get_size())
|
|
self._bbox_patch.set_mutation_scale(fontsize_in_pixel)
|
|
|
|
def _update_clip_properties(self):
|
|
if self._bbox_patch:
|
|
clipprops = dict(clip_box=self.clipbox,
|
|
clip_path=self._clippath,
|
|
clip_on=self._clipon)
|
|
self._bbox_patch.update(clipprops)
|
|
|
|
def set_clip_box(self, clipbox):
|
|
# docstring inherited.
|
|
super().set_clip_box(clipbox)
|
|
self._update_clip_properties()
|
|
|
|
def set_clip_path(self, path, transform=None):
|
|
# docstring inherited.
|
|
super().set_clip_path(path, transform)
|
|
self._update_clip_properties()
|
|
|
|
def set_clip_on(self, b):
|
|
# docstring inherited.
|
|
super().set_clip_on(b)
|
|
self._update_clip_properties()
|
|
|
|
def get_wrap(self):
|
|
"""Return whether the text can be wrapped."""
|
|
return self._wrap
|
|
|
|
def set_wrap(self, wrap):
|
|
"""
|
|
Set whether the text can be wrapped.
|
|
|
|
Parameters
|
|
----------
|
|
wrap : bool
|
|
|
|
Notes
|
|
-----
|
|
Wrapping does not work together with
|
|
``savefig(..., bbox_inches='tight')`` (which is also used internally
|
|
by ``%matplotlib inline`` in IPython/Jupyter). The 'tight' setting
|
|
rescales the canvas to accommodate all content and happens before
|
|
wrapping.
|
|
"""
|
|
self._wrap = wrap
|
|
|
|
def _get_wrap_line_width(self):
|
|
"""
|
|
Return the maximum line width for wrapping text based on the current
|
|
orientation.
|
|
"""
|
|
x0, y0 = self.get_transform().transform(self.get_position())
|
|
figure_box = self.get_figure().get_window_extent()
|
|
|
|
# Calculate available width based on text alignment
|
|
alignment = self.get_horizontalalignment()
|
|
self.set_rotation_mode('anchor')
|
|
rotation = self.get_rotation()
|
|
|
|
left = self._get_dist_to_box(rotation, x0, y0, figure_box)
|
|
right = self._get_dist_to_box(
|
|
(180 + rotation) % 360, x0, y0, figure_box)
|
|
|
|
if alignment == 'left':
|
|
line_width = left
|
|
elif alignment == 'right':
|
|
line_width = right
|
|
else:
|
|
line_width = 2 * min(left, right)
|
|
|
|
return line_width
|
|
|
|
def _get_dist_to_box(self, rotation, x0, y0, figure_box):
|
|
"""
|
|
Return the distance from the given points to the boundaries of a
|
|
rotated box, in pixels.
|
|
"""
|
|
if rotation > 270:
|
|
quad = rotation - 270
|
|
h1 = y0 / math.cos(math.radians(quad))
|
|
h2 = (figure_box.x1 - x0) / math.cos(math.radians(90 - quad))
|
|
elif rotation > 180:
|
|
quad = rotation - 180
|
|
h1 = x0 / math.cos(math.radians(quad))
|
|
h2 = y0 / math.cos(math.radians(90 - quad))
|
|
elif rotation > 90:
|
|
quad = rotation - 90
|
|
h1 = (figure_box.y1 - y0) / math.cos(math.radians(quad))
|
|
h2 = x0 / math.cos(math.radians(90 - quad))
|
|
else:
|
|
h1 = (figure_box.x1 - x0) / math.cos(math.radians(rotation))
|
|
h2 = (figure_box.y1 - y0) / math.cos(math.radians(90 - rotation))
|
|
|
|
return min(h1, h2)
|
|
|
|
def _get_rendered_text_width(self, text):
|
|
"""
|
|
Return the width of a given text string, in pixels.
|
|
"""
|
|
|
|
w, h, d = self._renderer.get_text_width_height_descent(
|
|
text,
|
|
self.get_fontproperties(),
|
|
cbook.is_math_text(text))
|
|
return math.ceil(w)
|
|
|
|
def _get_wrapped_text(self):
|
|
"""
|
|
Return a copy of the text string with new lines added so that the text
|
|
is wrapped relative to the parent figure (if `get_wrap` is True).
|
|
"""
|
|
if not self.get_wrap():
|
|
return self.get_text()
|
|
|
|
# Not fit to handle breaking up latex syntax correctly, so
|
|
# ignore latex for now.
|
|
if self.get_usetex():
|
|
return self.get_text()
|
|
|
|
# Build the line incrementally, for a more accurate measure of length
|
|
line_width = self._get_wrap_line_width()
|
|
wrapped_lines = []
|
|
|
|
# New lines in the user's text force a split
|
|
unwrapped_lines = self.get_text().split('\n')
|
|
|
|
# Now wrap each individual unwrapped line
|
|
for unwrapped_line in unwrapped_lines:
|
|
|
|
sub_words = unwrapped_line.split(' ')
|
|
# Remove items from sub_words as we go, so stop when empty
|
|
while len(sub_words) > 0:
|
|
if len(sub_words) == 1:
|
|
# Only one word, so just add it to the end
|
|
wrapped_lines.append(sub_words.pop(0))
|
|
continue
|
|
|
|
for i in range(2, len(sub_words) + 1):
|
|
# Get width of all words up to and including here
|
|
line = ' '.join(sub_words[:i])
|
|
current_width = self._get_rendered_text_width(line)
|
|
|
|
# If all these words are too wide, append all not including
|
|
# last word
|
|
if current_width > line_width:
|
|
wrapped_lines.append(' '.join(sub_words[:i - 1]))
|
|
sub_words = sub_words[i - 1:]
|
|
break
|
|
|
|
# Otherwise if all words fit in the width, append them all
|
|
elif i == len(sub_words):
|
|
wrapped_lines.append(' '.join(sub_words[:i]))
|
|
sub_words = []
|
|
break
|
|
|
|
return '\n'.join(wrapped_lines)
|
|
|
|
@artist.allow_rasterization
|
|
def draw(self, renderer):
|
|
# docstring inherited
|
|
|
|
if renderer is not None:
|
|
self._renderer = renderer
|
|
if not self.get_visible():
|
|
return
|
|
if self.get_text() == '':
|
|
return
|
|
|
|
renderer.open_group('text', self.get_gid())
|
|
|
|
with self._cm_set(text=self._get_wrapped_text()):
|
|
bbox, info, descent = self._get_layout(renderer)
|
|
trans = self.get_transform()
|
|
|
|
# don't use self.get_position here, which refers to text
|
|
# position in Text:
|
|
posx = float(self.convert_xunits(self._x))
|
|
posy = float(self.convert_yunits(self._y))
|
|
posx, posy = trans.transform((posx, posy))
|
|
if not np.isfinite(posx) or not np.isfinite(posy):
|
|
_log.warning("posx and posy should be finite values")
|
|
return
|
|
canvasw, canvash = renderer.get_canvas_width_height()
|
|
|
|
# Update the location and size of the bbox
|
|
# (`.patches.FancyBboxPatch`), and draw it.
|
|
if self._bbox_patch:
|
|
self.update_bbox_position_size(renderer)
|
|
self._bbox_patch.draw(renderer)
|
|
|
|
gc = renderer.new_gc()
|
|
gc.set_foreground(self.get_color())
|
|
gc.set_alpha(self.get_alpha())
|
|
gc.set_url(self._url)
|
|
gc.set_antialiased(self._antialiased)
|
|
self._set_gc_clip(gc)
|
|
|
|
angle = self.get_rotation()
|
|
|
|
for line, wh, x, y in info:
|
|
|
|
mtext = self if len(info) == 1 else None
|
|
x = x + posx
|
|
y = y + posy
|
|
if renderer.flipy():
|
|
y = canvash - y
|
|
clean_line, ismath = self._preprocess_math(line)
|
|
|
|
if self.get_path_effects():
|
|
from matplotlib.patheffects import PathEffectRenderer
|
|
textrenderer = PathEffectRenderer(
|
|
self.get_path_effects(), renderer)
|
|
else:
|
|
textrenderer = renderer
|
|
|
|
if self.get_usetex():
|
|
textrenderer.draw_tex(gc, x, y, clean_line,
|
|
self._fontproperties, angle,
|
|
mtext=mtext)
|
|
else:
|
|
textrenderer.draw_text(gc, x, y, clean_line,
|
|
self._fontproperties, angle,
|
|
ismath=ismath, mtext=mtext)
|
|
|
|
gc.restore()
|
|
renderer.close_group('text')
|
|
self.stale = False
|
|
|
|
def get_color(self):
|
|
"""Return the color of the text."""
|
|
return self._color
|
|
|
|
def get_fontproperties(self):
|
|
"""Return the `.font_manager.FontProperties`."""
|
|
return self._fontproperties
|
|
|
|
def get_fontfamily(self):
|
|
"""
|
|
Return the list of font families used for font lookup.
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.get_family
|
|
"""
|
|
return self._fontproperties.get_family()
|
|
|
|
def get_fontname(self):
|
|
"""
|
|
Return the font name as a string.
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.get_name
|
|
"""
|
|
return self._fontproperties.get_name()
|
|
|
|
def get_fontstyle(self):
|
|
"""
|
|
Return the font style as a string.
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.get_style
|
|
"""
|
|
return self._fontproperties.get_style()
|
|
|
|
def get_fontsize(self):
|
|
"""
|
|
Return the font size as an integer.
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.get_size_in_points
|
|
"""
|
|
return self._fontproperties.get_size_in_points()
|
|
|
|
def get_fontvariant(self):
|
|
"""
|
|
Return the font variant as a string.
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.get_variant
|
|
"""
|
|
return self._fontproperties.get_variant()
|
|
|
|
def get_fontweight(self):
|
|
"""
|
|
Return the font weight as a string or a number.
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.get_weight
|
|
"""
|
|
return self._fontproperties.get_weight()
|
|
|
|
def get_stretch(self):
|
|
"""
|
|
Return the font stretch as a string or a number.
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.get_stretch
|
|
"""
|
|
return self._fontproperties.get_stretch()
|
|
|
|
def get_horizontalalignment(self):
|
|
"""
|
|
Return the horizontal alignment as a string. Will be one of
|
|
'left', 'center' or 'right'.
|
|
"""
|
|
return self._horizontalalignment
|
|
|
|
def get_unitless_position(self):
|
|
"""Return the (x, y) unitless position of the text."""
|
|
# This will get the position with all unit information stripped away.
|
|
# This is here for convenience since it is done in several locations.
|
|
x = float(self.convert_xunits(self._x))
|
|
y = float(self.convert_yunits(self._y))
|
|
return x, y
|
|
|
|
def get_position(self):
|
|
"""Return the (x, y) position of the text."""
|
|
# This should return the same data (possible unitized) as was
|
|
# specified with 'set_x' and 'set_y'.
|
|
return self._x, self._y
|
|
|
|
def get_text(self):
|
|
"""Return the text string."""
|
|
return self._text
|
|
|
|
def get_verticalalignment(self):
|
|
"""
|
|
Return the vertical alignment as a string. Will be one of
|
|
'top', 'center', 'bottom', 'baseline' or 'center_baseline'.
|
|
"""
|
|
return self._verticalalignment
|
|
|
|
def get_window_extent(self, renderer=None, dpi=None):
|
|
"""
|
|
Return the `.Bbox` bounding the text, in display units.
|
|
|
|
In addition to being used internally, this is useful for specifying
|
|
clickable regions in a png file on a web page.
|
|
|
|
Parameters
|
|
----------
|
|
renderer : Renderer, optional
|
|
A renderer is needed to compute the bounding box. If the artist
|
|
has already been drawn, the renderer is cached; thus, it is only
|
|
necessary to pass this argument when calling `get_window_extent`
|
|
before the first draw. In practice, it is usually easier to
|
|
trigger a draw first, e.g. by calling
|
|
`~.Figure.draw_without_rendering` or ``plt.show()``.
|
|
|
|
dpi : float, optional
|
|
The dpi value for computing the bbox, defaults to
|
|
``self.figure.dpi`` (*not* the renderer dpi); should be set e.g. if
|
|
to match regions with a figure saved with a custom dpi value.
|
|
"""
|
|
if not self.get_visible():
|
|
return Bbox.unit()
|
|
if dpi is None:
|
|
dpi = self.figure.dpi
|
|
if self.get_text() == '':
|
|
with cbook._setattr_cm(self.figure, dpi=dpi):
|
|
tx, ty = self._get_xy_display()
|
|
return Bbox.from_bounds(tx, ty, 0, 0)
|
|
|
|
if renderer is not None:
|
|
self._renderer = renderer
|
|
if self._renderer is None:
|
|
self._renderer = self.figure._get_renderer()
|
|
if self._renderer is None:
|
|
raise RuntimeError(
|
|
"Cannot get window extent of text w/o renderer. You likely "
|
|
"want to call 'figure.draw_without_rendering()' first.")
|
|
|
|
with cbook._setattr_cm(self.figure, dpi=dpi):
|
|
bbox, info, descent = self._get_layout(self._renderer)
|
|
x, y = self.get_unitless_position()
|
|
x, y = self.get_transform().transform((x, y))
|
|
bbox = bbox.translated(x, y)
|
|
return bbox
|
|
|
|
def set_backgroundcolor(self, color):
|
|
"""
|
|
Set the background color of the text by updating the bbox.
|
|
|
|
Parameters
|
|
----------
|
|
color : color
|
|
|
|
See Also
|
|
--------
|
|
.set_bbox : To change the position of the bounding box
|
|
"""
|
|
if self._bbox_patch is None:
|
|
self.set_bbox(dict(facecolor=color, edgecolor=color))
|
|
else:
|
|
self._bbox_patch.update(dict(facecolor=color))
|
|
|
|
self._update_clip_properties()
|
|
self.stale = True
|
|
|
|
def set_color(self, color):
|
|
"""
|
|
Set the foreground color of the text
|
|
|
|
Parameters
|
|
----------
|
|
color : color
|
|
"""
|
|
# "auto" is only supported by axisartist, but we can just let it error
|
|
# out at draw time for simplicity.
|
|
if not cbook._str_equal(color, "auto"):
|
|
mpl.colors._check_color_like(color=color)
|
|
self._color = color
|
|
self.stale = True
|
|
|
|
def set_horizontalalignment(self, align):
|
|
"""
|
|
Set the horizontal alignment relative to the anchor point.
|
|
|
|
See also :doc:`/gallery/text_labels_and_annotations/text_alignment`.
|
|
|
|
Parameters
|
|
----------
|
|
align : {'left', 'center', 'right'}
|
|
"""
|
|
_api.check_in_list(['center', 'right', 'left'], align=align)
|
|
self._horizontalalignment = align
|
|
self.stale = True
|
|
|
|
def set_multialignment(self, align):
|
|
"""
|
|
Set the text alignment for multiline texts.
|
|
|
|
The layout of the bounding box of all the lines is determined by the
|
|
horizontalalignment and verticalalignment properties. This property
|
|
controls the alignment of the text lines within that box.
|
|
|
|
Parameters
|
|
----------
|
|
align : {'left', 'right', 'center'}
|
|
"""
|
|
_api.check_in_list(['center', 'right', 'left'], align=align)
|
|
self._multialignment = align
|
|
self.stale = True
|
|
|
|
def set_linespacing(self, spacing):
|
|
"""
|
|
Set the line spacing as a multiple of the font size.
|
|
|
|
The default line spacing is 1.2.
|
|
|
|
Parameters
|
|
----------
|
|
spacing : float (multiple of font size)
|
|
"""
|
|
_api.check_isinstance(Real, spacing=spacing)
|
|
self._linespacing = spacing
|
|
self.stale = True
|
|
|
|
def set_fontfamily(self, fontname):
|
|
"""
|
|
Set the font family. Can be either a single string, or a list of
|
|
strings in decreasing priority. Each string may be either a real font
|
|
name or a generic font class name. If the latter, the specific font
|
|
names will be looked up in the corresponding rcParams.
|
|
|
|
If a `Text` instance is constructed with ``fontfamily=None``, then the
|
|
font is set to :rc:`font.family`, and the
|
|
same is done when `set_fontfamily()` is called on an existing
|
|
`Text` instance.
|
|
|
|
Parameters
|
|
----------
|
|
fontname : {FONTNAME, 'serif', 'sans-serif', 'cursive', 'fantasy', \
|
|
'monospace'}
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.set_family
|
|
"""
|
|
self._fontproperties.set_family(fontname)
|
|
self.stale = True
|
|
|
|
def set_fontvariant(self, variant):
|
|
"""
|
|
Set the font variant.
|
|
|
|
Parameters
|
|
----------
|
|
variant : {'normal', 'small-caps'}
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.set_variant
|
|
"""
|
|
self._fontproperties.set_variant(variant)
|
|
self.stale = True
|
|
|
|
def set_fontstyle(self, fontstyle):
|
|
"""
|
|
Set the font style.
|
|
|
|
Parameters
|
|
----------
|
|
fontstyle : {'normal', 'italic', 'oblique'}
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.set_style
|
|
"""
|
|
self._fontproperties.set_style(fontstyle)
|
|
self.stale = True
|
|
|
|
def set_fontsize(self, fontsize):
|
|
"""
|
|
Set the font size.
|
|
|
|
Parameters
|
|
----------
|
|
fontsize : float or {'xx-small', 'x-small', 'small', 'medium', \
|
|
'large', 'x-large', 'xx-large'}
|
|
If a float, the fontsize in points. The string values denote sizes
|
|
relative to the default font size.
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.set_size
|
|
"""
|
|
self._fontproperties.set_size(fontsize)
|
|
self.stale = True
|
|
|
|
def get_math_fontfamily(self):
|
|
"""
|
|
Return the font family name for math text rendered by Matplotlib.
|
|
|
|
The default value is :rc:`mathtext.fontset`.
|
|
|
|
See Also
|
|
--------
|
|
set_math_fontfamily
|
|
"""
|
|
return self._fontproperties.get_math_fontfamily()
|
|
|
|
def set_math_fontfamily(self, fontfamily):
|
|
"""
|
|
Set the font family for math text rendered by Matplotlib.
|
|
|
|
This does only affect Matplotlib's own math renderer. It has no effect
|
|
when rendering with TeX (``usetex=True``).
|
|
|
|
Parameters
|
|
----------
|
|
fontfamily : str
|
|
The name of the font family.
|
|
|
|
Available font families are defined in the
|
|
:ref:`default matplotlibrc file
|
|
<customizing-with-matplotlibrc-files>`.
|
|
|
|
See Also
|
|
--------
|
|
get_math_fontfamily
|
|
"""
|
|
self._fontproperties.set_math_fontfamily(fontfamily)
|
|
|
|
def set_fontweight(self, weight):
|
|
"""
|
|
Set the font weight.
|
|
|
|
Parameters
|
|
----------
|
|
weight : {a numeric value in range 0-1000, 'ultralight', 'light', \
|
|
'normal', 'regular', 'book', 'medium', 'roman', 'semibold', 'demibold', \
|
|
'demi', 'bold', 'heavy', 'extra bold', 'black'}
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.set_weight
|
|
"""
|
|
self._fontproperties.set_weight(weight)
|
|
self.stale = True
|
|
|
|
def set_fontstretch(self, stretch):
|
|
"""
|
|
Set the font stretch (horizontal condensation or expansion).
|
|
|
|
Parameters
|
|
----------
|
|
stretch : {a numeric value in range 0-1000, 'ultra-condensed', \
|
|
'extra-condensed', 'condensed', 'semi-condensed', 'normal', 'semi-expanded', \
|
|
'expanded', 'extra-expanded', 'ultra-expanded'}
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.set_stretch
|
|
"""
|
|
self._fontproperties.set_stretch(stretch)
|
|
self.stale = True
|
|
|
|
def set_position(self, xy):
|
|
"""
|
|
Set the (*x*, *y*) position of the text.
|
|
|
|
Parameters
|
|
----------
|
|
xy : (float, float)
|
|
"""
|
|
self.set_x(xy[0])
|
|
self.set_y(xy[1])
|
|
|
|
def set_x(self, x):
|
|
"""
|
|
Set the *x* position of the text.
|
|
|
|
Parameters
|
|
----------
|
|
x : float
|
|
"""
|
|
self._x = x
|
|
self.stale = True
|
|
|
|
def set_y(self, y):
|
|
"""
|
|
Set the *y* position of the text.
|
|
|
|
Parameters
|
|
----------
|
|
y : float
|
|
"""
|
|
self._y = y
|
|
self.stale = True
|
|
|
|
def set_rotation(self, s):
|
|
"""
|
|
Set the rotation of the text.
|
|
|
|
Parameters
|
|
----------
|
|
s : float or {'vertical', 'horizontal'}
|
|
The rotation angle in degrees in mathematically positive direction
|
|
(counterclockwise). 'horizontal' equals 0, 'vertical' equals 90.
|
|
"""
|
|
if isinstance(s, Real):
|
|
self._rotation = float(s) % 360
|
|
elif cbook._str_equal(s, 'horizontal') or s is None:
|
|
self._rotation = 0.
|
|
elif cbook._str_equal(s, 'vertical'):
|
|
self._rotation = 90.
|
|
else:
|
|
raise ValueError("rotation must be 'vertical', 'horizontal' or "
|
|
f"a number, not {s}")
|
|
self.stale = True
|
|
|
|
def set_transform_rotates_text(self, t):
|
|
"""
|
|
Whether rotations of the transform affect the text direction.
|
|
|
|
Parameters
|
|
----------
|
|
t : bool
|
|
"""
|
|
self._transform_rotates_text = t
|
|
self.stale = True
|
|
|
|
def set_verticalalignment(self, align):
|
|
"""
|
|
Set the vertical alignment relative to the anchor point.
|
|
|
|
See also :doc:`/gallery/text_labels_and_annotations/text_alignment`.
|
|
|
|
Parameters
|
|
----------
|
|
align : {'baseline', 'bottom', 'center', 'center_baseline', 'top'}
|
|
"""
|
|
_api.check_in_list(
|
|
['top', 'bottom', 'center', 'baseline', 'center_baseline'],
|
|
align=align)
|
|
self._verticalalignment = align
|
|
self.stale = True
|
|
|
|
def set_text(self, s):
|
|
r"""
|
|
Set the text string *s*.
|
|
|
|
It may contain newlines (``\n``) or math in LaTeX syntax.
|
|
|
|
Parameters
|
|
----------
|
|
s : object
|
|
Any object gets converted to its `str` representation, except for
|
|
``None`` which is converted to an empty string.
|
|
"""
|
|
s = '' if s is None else str(s)
|
|
if s != self._text:
|
|
self._text = s
|
|
self.stale = True
|
|
|
|
def _preprocess_math(self, s):
|
|
"""
|
|
Return the string *s* after mathtext preprocessing, and the kind of
|
|
mathtext support needed.
|
|
|
|
- If *self* is configured to use TeX, return *s* unchanged except that
|
|
a single space gets escaped, and the flag "TeX".
|
|
- Otherwise, if *s* is mathtext (has an even number of unescaped dollar
|
|
signs) and ``parse_math`` is not set to False, return *s* and the
|
|
flag True.
|
|
- Otherwise, return *s* with dollar signs unescaped, and the flag
|
|
False.
|
|
"""
|
|
if self.get_usetex():
|
|
if s == " ":
|
|
s = r"\ "
|
|
return s, "TeX"
|
|
elif not self.get_parse_math():
|
|
return s, False
|
|
elif cbook.is_math_text(s):
|
|
return s, True
|
|
else:
|
|
return s.replace(r"\$", "$"), False
|
|
|
|
def set_fontproperties(self, fp):
|
|
"""
|
|
Set the font properties that control the text.
|
|
|
|
Parameters
|
|
----------
|
|
fp : `.font_manager.FontProperties` or `str` or `pathlib.Path`
|
|
If a `str`, it is interpreted as a fontconfig pattern parsed by
|
|
`.FontProperties`. If a `pathlib.Path`, it is interpreted as the
|
|
absolute path to a font file.
|
|
"""
|
|
self._fontproperties = FontProperties._from_any(fp).copy()
|
|
self.stale = True
|
|
|
|
def set_usetex(self, usetex):
|
|
"""
|
|
Parameters
|
|
----------
|
|
usetex : bool or None
|
|
Whether to render using TeX, ``None`` means to use
|
|
:rc:`text.usetex`.
|
|
"""
|
|
if usetex is None:
|
|
self._usetex = mpl.rcParams['text.usetex']
|
|
else:
|
|
self._usetex = bool(usetex)
|
|
self.stale = True
|
|
|
|
def get_usetex(self):
|
|
"""Return whether this `Text` object uses TeX for rendering."""
|
|
return self._usetex
|
|
|
|
def set_parse_math(self, parse_math):
|
|
"""
|
|
Override switch to disable any mathtext parsing for this `Text`.
|
|
|
|
Parameters
|
|
----------
|
|
parse_math : bool
|
|
If False, this `Text` will never use mathtext. If True, mathtext
|
|
will be used if there is an even number of unescaped dollar signs.
|
|
"""
|
|
self._parse_math = bool(parse_math)
|
|
|
|
def get_parse_math(self):
|
|
"""Return whether mathtext parsing is considered for this `Text`."""
|
|
return self._parse_math
|
|
|
|
def set_fontname(self, fontname):
|
|
"""
|
|
Alias for `set_fontfamily`.
|
|
|
|
One-way alias only: the getter differs.
|
|
|
|
Parameters
|
|
----------
|
|
fontname : {FONTNAME, 'serif', 'sans-serif', 'cursive', 'fantasy', \
|
|
'monospace'}
|
|
|
|
See Also
|
|
--------
|
|
.font_manager.FontProperties.set_family
|
|
|
|
"""
|
|
self.set_fontfamily(fontname)
|
|
|
|
|
|
class OffsetFrom:
|
|
"""Callable helper class for working with `Annotation`."""
|
|
|
|
def __init__(self, artist, ref_coord, unit="points"):
|
|
"""
|
|
Parameters
|
|
----------
|
|
artist : `~matplotlib.artist.Artist` or `.BboxBase` or `.Transform`
|
|
The object to compute the offset from.
|
|
|
|
ref_coord : (float, float)
|
|
If *artist* is an `.Artist` or `.BboxBase`, this values is
|
|
the location to of the offset origin in fractions of the
|
|
*artist* bounding box.
|
|
|
|
If *artist* is a transform, the offset origin is the
|
|
transform applied to this value.
|
|
|
|
unit : {'points, 'pixels'}, default: 'points'
|
|
The screen units to use (pixels or points) for the offset input.
|
|
"""
|
|
self._artist = artist
|
|
x, y = ref_coord # Make copy when ref_coord is an array (and check the shape).
|
|
self._ref_coord = x, y
|
|
self.set_unit(unit)
|
|
|
|
def set_unit(self, unit):
|
|
"""
|
|
Set the unit for input to the transform used by ``__call__``.
|
|
|
|
Parameters
|
|
----------
|
|
unit : {'points', 'pixels'}
|
|
"""
|
|
_api.check_in_list(["points", "pixels"], unit=unit)
|
|
self._unit = unit
|
|
|
|
def get_unit(self):
|
|
"""Return the unit for input to the transform used by ``__call__``."""
|
|
return self._unit
|
|
|
|
def __call__(self, renderer):
|
|
"""
|
|
Return the offset transform.
|
|
|
|
Parameters
|
|
----------
|
|
renderer : `RendererBase`
|
|
The renderer to use to compute the offset
|
|
|
|
Returns
|
|
-------
|
|
`Transform`
|
|
Maps (x, y) in pixel or point units to screen units
|
|
relative to the given artist.
|
|
"""
|
|
if isinstance(self._artist, Artist):
|
|
bbox = self._artist.get_window_extent(renderer)
|
|
xf, yf = self._ref_coord
|
|
x = bbox.x0 + bbox.width * xf
|
|
y = bbox.y0 + bbox.height * yf
|
|
elif isinstance(self._artist, BboxBase):
|
|
bbox = self._artist
|
|
xf, yf = self._ref_coord
|
|
x = bbox.x0 + bbox.width * xf
|
|
y = bbox.y0 + bbox.height * yf
|
|
elif isinstance(self._artist, Transform):
|
|
x, y = self._artist.transform(self._ref_coord)
|
|
else:
|
|
_api.check_isinstance((Artist, BboxBase, Transform), artist=self._artist)
|
|
scale = 1 if self._unit == "pixels" else renderer.points_to_pixels(1)
|
|
return Affine2D().scale(scale).translate(x, y)
|
|
|
|
|
|
class _AnnotationBase:
|
|
def __init__(self,
|
|
xy,
|
|
xycoords='data',
|
|
annotation_clip=None):
|
|
|
|
x, y = xy # Make copy when xy is an array (and check the shape).
|
|
self.xy = x, y
|
|
self.xycoords = xycoords
|
|
self.set_annotation_clip(annotation_clip)
|
|
|
|
self._draggable = None
|
|
|
|
def _get_xy(self, renderer, xy, coords):
|
|
x, y = xy
|
|
xcoord, ycoord = coords if isinstance(coords, tuple) else (coords, coords)
|
|
if xcoord == 'data':
|
|
x = float(self.convert_xunits(x))
|
|
if ycoord == 'data':
|
|
y = float(self.convert_yunits(y))
|
|
return self._get_xy_transform(renderer, coords).transform((x, y))
|
|
|
|
def _get_xy_transform(self, renderer, coords):
|
|
|
|
if isinstance(coords, tuple):
|
|
xcoord, ycoord = coords
|
|
from matplotlib.transforms import blended_transform_factory
|
|
tr1 = self._get_xy_transform(renderer, xcoord)
|
|
tr2 = self._get_xy_transform(renderer, ycoord)
|
|
return blended_transform_factory(tr1, tr2)
|
|
elif callable(coords):
|
|
tr = coords(renderer)
|
|
if isinstance(tr, BboxBase):
|
|
return BboxTransformTo(tr)
|
|
elif isinstance(tr, Transform):
|
|
return tr
|
|
else:
|
|
raise TypeError(
|
|
f"xycoords callable must return a BboxBase or Transform, not a "
|
|
f"{type(tr).__name__}")
|
|
elif isinstance(coords, Artist):
|
|
bbox = coords.get_window_extent(renderer)
|
|
return BboxTransformTo(bbox)
|
|
elif isinstance(coords, BboxBase):
|
|
return BboxTransformTo(coords)
|
|
elif isinstance(coords, Transform):
|
|
return coords
|
|
elif not isinstance(coords, str):
|
|
raise TypeError(
|
|
f"'xycoords' must be an instance of str, tuple[str, str], Artist, "
|
|
f"Transform, or Callable, not a {type(coords).__name__}")
|
|
|
|
if coords == 'data':
|
|
return self.axes.transData
|
|
elif coords == 'polar':
|
|
from matplotlib.projections import PolarAxes
|
|
tr = PolarAxes.PolarTransform()
|
|
trans = tr + self.axes.transData
|
|
return trans
|
|
|
|
try:
|
|
bbox_name, unit = coords.split()
|
|
except ValueError: # i.e. len(coords.split()) != 2.
|
|
raise ValueError(f"{coords!r} is not a valid coordinate") from None
|
|
|
|
bbox0, xy0 = None, None
|
|
|
|
# if unit is offset-like
|
|
if bbox_name == "figure":
|
|
bbox0 = self.figure.figbbox
|
|
elif bbox_name == "subfigure":
|
|
bbox0 = self.figure.bbox
|
|
elif bbox_name == "axes":
|
|
bbox0 = self.axes.bbox
|
|
|
|
# reference x, y in display coordinate
|
|
if bbox0 is not None:
|
|
xy0 = bbox0.p0
|
|
elif bbox_name == "offset":
|
|
xy0 = self._get_position_xy(renderer)
|
|
else:
|
|
raise ValueError(f"{coords!r} is not a valid coordinate")
|
|
|
|
if unit == "points":
|
|
tr = Affine2D().scale(self.figure.dpi / 72) # dpi/72 dots per point
|
|
elif unit == "pixels":
|
|
tr = Affine2D()
|
|
elif unit == "fontsize":
|
|
tr = Affine2D().scale(self.get_size() * self.figure.dpi / 72)
|
|
elif unit == "fraction":
|
|
tr = Affine2D().scale(*bbox0.size)
|
|
else:
|
|
raise ValueError(f"{unit!r} is not a recognized unit")
|
|
|
|
return tr.translate(*xy0)
|
|
|
|
def set_annotation_clip(self, b):
|
|
"""
|
|
Set the annotation's clipping behavior.
|
|
|
|
Parameters
|
|
----------
|
|
b : bool or None
|
|
- True: The annotation will be clipped when ``self.xy`` is
|
|
outside the axes.
|
|
- False: The annotation will always be drawn.
|
|
- None: The annotation will be clipped when ``self.xy`` is
|
|
outside the axes and ``self.xycoords == "data"``.
|
|
"""
|
|
self._annotation_clip = b
|
|
|
|
def get_annotation_clip(self):
|
|
"""
|
|
Return the annotation's clipping behavior.
|
|
|
|
See `set_annotation_clip` for the meaning of return values.
|
|
"""
|
|
return self._annotation_clip
|
|
|
|
def _get_position_xy(self, renderer):
|
|
"""Return the pixel position of the annotated point."""
|
|
return self._get_xy(renderer, self.xy, self.xycoords)
|
|
|
|
def _check_xy(self, renderer=None):
|
|
"""Check whether the annotation at *xy_pixel* should be drawn."""
|
|
if renderer is None:
|
|
renderer = self.figure._get_renderer()
|
|
b = self.get_annotation_clip()
|
|
if b or (b is None and self.xycoords == "data"):
|
|
# check if self.xy is inside the axes.
|
|
xy_pixel = self._get_position_xy(renderer)
|
|
return self.axes.contains_point(xy_pixel)
|
|
return True
|
|
|
|
def draggable(self, state=None, use_blit=False):
|
|
"""
|
|
Set whether the annotation is draggable with the mouse.
|
|
|
|
Parameters
|
|
----------
|
|
state : bool or None
|
|
- True or False: set the draggability.
|
|
- None: toggle the draggability.
|
|
use_blit : bool, default: False
|
|
Use blitting for faster image composition. For details see
|
|
:ref:`func-animation`.
|
|
|
|
Returns
|
|
-------
|
|
DraggableAnnotation or None
|
|
If the annotation is draggable, the corresponding
|
|
`.DraggableAnnotation` helper is returned.
|
|
"""
|
|
from matplotlib.offsetbox import DraggableAnnotation
|
|
is_draggable = self._draggable is not None
|
|
|
|
# if state is None we'll toggle
|
|
if state is None:
|
|
state = not is_draggable
|
|
|
|
if state:
|
|
if self._draggable is None:
|
|
self._draggable = DraggableAnnotation(self, use_blit)
|
|
else:
|
|
if self._draggable is not None:
|
|
self._draggable.disconnect()
|
|
self._draggable = None
|
|
|
|
return self._draggable
|
|
|
|
|
|
class Annotation(Text, _AnnotationBase):
|
|
"""
|
|
An `.Annotation` is a `.Text` that can refer to a specific position *xy*.
|
|
Optionally an arrow pointing from the text to *xy* can be drawn.
|
|
|
|
Attributes
|
|
----------
|
|
xy
|
|
The annotated position.
|
|
xycoords
|
|
The coordinate system for *xy*.
|
|
arrow_patch
|
|
A `.FancyArrowPatch` to point from *xytext* to *xy*.
|
|
"""
|
|
|
|
def __str__(self):
|
|
return f"Annotation({self.xy[0]:g}, {self.xy[1]:g}, {self._text!r})"
|
|
|
|
def __init__(self, text, xy,
|
|
xytext=None,
|
|
xycoords='data',
|
|
textcoords=None,
|
|
arrowprops=None,
|
|
annotation_clip=None,
|
|
**kwargs):
|
|
"""
|
|
Annotate the point *xy* with text *text*.
|
|
|
|
In the simplest form, the text is placed at *xy*.
|
|
|
|
Optionally, the text can be displayed in another position *xytext*.
|
|
An arrow pointing from the text to the annotated point *xy* can then
|
|
be added by defining *arrowprops*.
|
|
|
|
Parameters
|
|
----------
|
|
text : str
|
|
The text of the annotation.
|
|
|
|
xy : (float, float)
|
|
The point *(x, y)* to annotate. The coordinate system is determined
|
|
by *xycoords*.
|
|
|
|
xytext : (float, float), default: *xy*
|
|
The position *(x, y)* to place the text at. The coordinate system
|
|
is determined by *textcoords*.
|
|
|
|
xycoords : single or two-tuple of str or `.Artist` or `.Transform` or \
|
|
callable, default: 'data'
|
|
|
|
The coordinate system that *xy* is given in. The following types
|
|
of values are supported:
|
|
|
|
- One of the following strings:
|
|
|
|
==================== ============================================
|
|
Value Description
|
|
==================== ============================================
|
|
'figure points' Points from the lower left of the figure
|
|
'figure pixels' Pixels from the lower left of the figure
|
|
'figure fraction' Fraction of figure from lower left
|
|
'subfigure points' Points from the lower left of the subfigure
|
|
'subfigure pixels' Pixels from the lower left of the subfigure
|
|
'subfigure fraction' Fraction of subfigure from lower left
|
|
'axes points' Points from lower left corner of axes
|
|
'axes pixels' Pixels from lower left corner of axes
|
|
'axes fraction' Fraction of axes from lower left
|
|
'data' Use the coordinate system of the object
|
|
being annotated (default)
|
|
'polar' *(theta, r)* if not native 'data'
|
|
coordinates
|
|
==================== ============================================
|
|
|
|
Note that 'subfigure pixels' and 'figure pixels' are the same
|
|
for the parent figure, so users who want code that is usable in
|
|
a subfigure can use 'subfigure pixels'.
|
|
|
|
- An `.Artist`: *xy* is interpreted as a fraction of the artist's
|
|
`~matplotlib.transforms.Bbox`. E.g. *(0, 0)* would be the lower
|
|
left corner of the bounding box and *(0.5, 1)* would be the
|
|
center top of the bounding box.
|
|
|
|
- A `.Transform` to transform *xy* to screen coordinates.
|
|
|
|
- A function with one of the following signatures::
|
|
|
|
def transform(renderer) -> Bbox
|
|
def transform(renderer) -> Transform
|
|
|
|
where *renderer* is a `.RendererBase` subclass.
|
|
|
|
The result of the function is interpreted like the `.Artist` and
|
|
`.Transform` cases above.
|
|
|
|
- A tuple *(xcoords, ycoords)* specifying separate coordinate
|
|
systems for *x* and *y*. *xcoords* and *ycoords* must each be
|
|
of one of the above described types.
|
|
|
|
See :ref:`plotting-guide-annotation` for more details.
|
|
|
|
textcoords : single or two-tuple of str or `.Artist` or `.Transform` \
|
|
or callable, default: value of *xycoords*
|
|
The coordinate system that *xytext* is given in.
|
|
|
|
All *xycoords* values are valid as well as the following strings:
|
|
|
|
================= =================================================
|
|
Value Description
|
|
================= =================================================
|
|
'offset points' Offset, in points, from the *xy* value
|
|
'offset pixels' Offset, in pixels, from the *xy* value
|
|
'offset fontsize' Offset, relative to fontsize, from the *xy* value
|
|
================= =================================================
|
|
|
|
arrowprops : dict, optional
|
|
The properties used to draw a `.FancyArrowPatch` arrow between the
|
|
positions *xy* and *xytext*. Defaults to None, i.e. no arrow is
|
|
drawn.
|
|
|
|
For historical reasons there are two different ways to specify
|
|
arrows, "simple" and "fancy":
|
|
|
|
**Simple arrow:**
|
|
|
|
If *arrowprops* does not contain the key 'arrowstyle' the
|
|
allowed keys are:
|
|
|
|
========== =================================================
|
|
Key Description
|
|
========== =================================================
|
|
width The width of the arrow in points
|
|
headwidth The width of the base of the arrow head in points
|
|
headlength The length of the arrow head in points
|
|
shrink Fraction of total length to shrink from both ends
|
|
? Any `.FancyArrowPatch` property
|
|
========== =================================================
|
|
|
|
The arrow is attached to the edge of the text box, the exact
|
|
position (corners or centers) depending on where it's pointing to.
|
|
|
|
**Fancy arrow:**
|
|
|
|
This is used if 'arrowstyle' is provided in the *arrowprops*.
|
|
|
|
Valid keys are the following `.FancyArrowPatch` parameters:
|
|
|
|
=============== ===================================
|
|
Key Description
|
|
=============== ===================================
|
|
arrowstyle The arrow style
|
|
connectionstyle The connection style
|
|
relpos See below; default is (0.5, 0.5)
|
|
patchA Default is bounding box of the text
|
|
patchB Default is None
|
|
shrinkA Default is 2 points
|
|
shrinkB Default is 2 points
|
|
mutation_scale Default is text size (in points)
|
|
mutation_aspect Default is 1
|
|
? Any `.FancyArrowPatch` property
|
|
=============== ===================================
|
|
|
|
The exact starting point position of the arrow is defined by
|
|
*relpos*. It's a tuple of relative coordinates of the text box,
|
|
where (0, 0) is the lower left corner and (1, 1) is the upper
|
|
right corner. Values <0 and >1 are supported and specify points
|
|
outside the text box. By default (0.5, 0.5), so the starting point
|
|
is centered in the text box.
|
|
|
|
annotation_clip : bool or None, default: None
|
|
Whether to clip (i.e. not draw) the annotation when the annotation
|
|
point *xy* is outside the axes area.
|
|
|
|
- If *True*, the annotation will be clipped when *xy* is outside
|
|
the axes.
|
|
- If *False*, the annotation will always be drawn.
|
|
- If *None*, the annotation will be clipped when *xy* is outside
|
|
the axes and *xycoords* is 'data'.
|
|
|
|
**kwargs
|
|
Additional kwargs are passed to `.Text`.
|
|
|
|
Returns
|
|
-------
|
|
`.Annotation`
|
|
|
|
See Also
|
|
--------
|
|
:ref:`plotting-guide-annotation`
|
|
|
|
"""
|
|
_AnnotationBase.__init__(self,
|
|
xy,
|
|
xycoords=xycoords,
|
|
annotation_clip=annotation_clip)
|
|
# warn about wonky input data
|
|
if (xytext is None and
|
|
textcoords is not None and
|
|
textcoords != xycoords):
|
|
_api.warn_external("You have used the `textcoords` kwarg, but "
|
|
"not the `xytext` kwarg. This can lead to "
|
|
"surprising results.")
|
|
|
|
# clean up textcoords and assign default
|
|
if textcoords is None:
|
|
textcoords = self.xycoords
|
|
self._textcoords = textcoords
|
|
|
|
# cleanup xytext defaults
|
|
if xytext is None:
|
|
xytext = self.xy
|
|
x, y = xytext
|
|
|
|
self.arrowprops = arrowprops
|
|
if arrowprops is not None:
|
|
arrowprops = arrowprops.copy()
|
|
if "arrowstyle" in arrowprops:
|
|
self._arrow_relpos = arrowprops.pop("relpos", (0.5, 0.5))
|
|
else:
|
|
# modified YAArrow API to be used with FancyArrowPatch
|
|
for key in ['width', 'headwidth', 'headlength', 'shrink']:
|
|
arrowprops.pop(key, None)
|
|
if 'frac' in arrowprops:
|
|
_api.warn_deprecated(
|
|
"3.8", name="the (unused) 'frac' key in 'arrowprops'")
|
|
arrowprops.pop("frac")
|
|
self.arrow_patch = FancyArrowPatch((0, 0), (1, 1), **arrowprops)
|
|
else:
|
|
self.arrow_patch = None
|
|
|
|
# Must come last, as some kwargs may be propagated to arrow_patch.
|
|
Text.__init__(self, x, y, text, **kwargs)
|
|
|
|
@_api.rename_parameter("3.8", "event", "mouseevent")
|
|
def contains(self, mouseevent):
|
|
if self._different_canvas(mouseevent):
|
|
return False, {}
|
|
contains, tinfo = Text.contains(self, mouseevent)
|
|
if self.arrow_patch is not None:
|
|
in_patch, _ = self.arrow_patch.contains(mouseevent)
|
|
contains = contains or in_patch
|
|
return contains, tinfo
|
|
|
|
@property
|
|
def xycoords(self):
|
|
return self._xycoords
|
|
|
|
@xycoords.setter
|
|
def xycoords(self, xycoords):
|
|
def is_offset(s):
|
|
return isinstance(s, str) and s.startswith("offset")
|
|
|
|
if (isinstance(xycoords, tuple) and any(map(is_offset, xycoords))
|
|
or is_offset(xycoords)):
|
|
raise ValueError("xycoords cannot be an offset coordinate")
|
|
self._xycoords = xycoords
|
|
|
|
@property
|
|
def xyann(self):
|
|
"""
|
|
The text position.
|
|
|
|
See also *xytext* in `.Annotation`.
|
|
"""
|
|
return self.get_position()
|
|
|
|
@xyann.setter
|
|
def xyann(self, xytext):
|
|
self.set_position(xytext)
|
|
|
|
def get_anncoords(self):
|
|
"""
|
|
Return the coordinate system to use for `.Annotation.xyann`.
|
|
|
|
See also *xycoords* in `.Annotation`.
|
|
"""
|
|
return self._textcoords
|
|
|
|
def set_anncoords(self, coords):
|
|
"""
|
|
Set the coordinate system to use for `.Annotation.xyann`.
|
|
|
|
See also *xycoords* in `.Annotation`.
|
|
"""
|
|
self._textcoords = coords
|
|
|
|
anncoords = property(get_anncoords, set_anncoords, doc="""
|
|
The coordinate system to use for `.Annotation.xyann`.""")
|
|
|
|
def set_figure(self, fig):
|
|
# docstring inherited
|
|
if self.arrow_patch is not None:
|
|
self.arrow_patch.set_figure(fig)
|
|
Artist.set_figure(self, fig)
|
|
|
|
def update_positions(self, renderer):
|
|
"""
|
|
Update the pixel positions of the annotation text and the arrow patch.
|
|
"""
|
|
# generate transformation
|
|
self.set_transform(self._get_xy_transform(renderer, self.anncoords))
|
|
|
|
arrowprops = self.arrowprops
|
|
if arrowprops is None:
|
|
return
|
|
|
|
bbox = Text.get_window_extent(self, renderer)
|
|
|
|
arrow_end = x1, y1 = self._get_position_xy(renderer) # Annotated pos.
|
|
|
|
ms = arrowprops.get("mutation_scale", self.get_size())
|
|
self.arrow_patch.set_mutation_scale(ms)
|
|
|
|
if "arrowstyle" not in arrowprops:
|
|
# Approximately simulate the YAArrow.
|
|
shrink = arrowprops.get('shrink', 0.0)
|
|
width = arrowprops.get('width', 4)
|
|
headwidth = arrowprops.get('headwidth', 12)
|
|
headlength = arrowprops.get('headlength', 12)
|
|
|
|
# NB: ms is in pts
|
|
stylekw = dict(head_length=headlength / ms,
|
|
head_width=headwidth / ms,
|
|
tail_width=width / ms)
|
|
|
|
self.arrow_patch.set_arrowstyle('simple', **stylekw)
|
|
|
|
# using YAArrow style:
|
|
# pick the corner of the text bbox closest to annotated point.
|
|
xpos = [(bbox.x0, 0), ((bbox.x0 + bbox.x1) / 2, 0.5), (bbox.x1, 1)]
|
|
ypos = [(bbox.y0, 0), ((bbox.y0 + bbox.y1) / 2, 0.5), (bbox.y1, 1)]
|
|
x, relposx = min(xpos, key=lambda v: abs(v[0] - x1))
|
|
y, relposy = min(ypos, key=lambda v: abs(v[0] - y1))
|
|
self._arrow_relpos = (relposx, relposy)
|
|
r = np.hypot(y - y1, x - x1)
|
|
shrink_pts = shrink * r / renderer.points_to_pixels(1)
|
|
self.arrow_patch.shrinkA = self.arrow_patch.shrinkB = shrink_pts
|
|
|
|
# adjust the starting point of the arrow relative to the textbox.
|
|
# TODO : Rotation needs to be accounted.
|
|
arrow_begin = bbox.p0 + bbox.size * self._arrow_relpos
|
|
# The arrow is drawn from arrow_begin to arrow_end. It will be first
|
|
# clipped by patchA and patchB. Then it will be shrunk by shrinkA and
|
|
# shrinkB (in points). If patchA is not set, self.bbox_patch is used.
|
|
self.arrow_patch.set_positions(arrow_begin, arrow_end)
|
|
|
|
if "patchA" in arrowprops:
|
|
patchA = arrowprops["patchA"]
|
|
elif self._bbox_patch:
|
|
patchA = self._bbox_patch
|
|
elif self.get_text() == "":
|
|
patchA = None
|
|
else:
|
|
pad = renderer.points_to_pixels(4)
|
|
patchA = Rectangle(
|
|
xy=(bbox.x0 - pad / 2, bbox.y0 - pad / 2),
|
|
width=bbox.width + pad, height=bbox.height + pad,
|
|
transform=IdentityTransform(), clip_on=False)
|
|
self.arrow_patch.set_patchA(patchA)
|
|
|
|
@artist.allow_rasterization
|
|
def draw(self, renderer):
|
|
# docstring inherited
|
|
if renderer is not None:
|
|
self._renderer = renderer
|
|
if not self.get_visible() or not self._check_xy(renderer):
|
|
return
|
|
# Update text positions before `Text.draw` would, so that the
|
|
# FancyArrowPatch is correctly positioned.
|
|
self.update_positions(renderer)
|
|
self.update_bbox_position_size(renderer)
|
|
if self.arrow_patch is not None: # FancyArrowPatch
|
|
if self.arrow_patch.figure is None and self.figure is not None:
|
|
self.arrow_patch.figure = self.figure
|
|
self.arrow_patch.draw(renderer)
|
|
# Draw text, including FancyBboxPatch, after FancyArrowPatch.
|
|
# Otherwise, a wedge arrowstyle can land partly on top of the Bbox.
|
|
Text.draw(self, renderer)
|
|
|
|
def get_window_extent(self, renderer=None):
|
|
# docstring inherited
|
|
# This block is the same as in Text.get_window_extent, but we need to
|
|
# set the renderer before calling update_positions().
|
|
if not self.get_visible() or not self._check_xy(renderer):
|
|
return Bbox.unit()
|
|
if renderer is not None:
|
|
self._renderer = renderer
|
|
if self._renderer is None:
|
|
self._renderer = self.figure._get_renderer()
|
|
if self._renderer is None:
|
|
raise RuntimeError('Cannot get window extent without renderer')
|
|
|
|
self.update_positions(self._renderer)
|
|
|
|
text_bbox = Text.get_window_extent(self)
|
|
bboxes = [text_bbox]
|
|
|
|
if self.arrow_patch is not None:
|
|
bboxes.append(self.arrow_patch.get_window_extent())
|
|
|
|
return Bbox.union(bboxes)
|
|
|
|
def get_tightbbox(self, renderer=None):
|
|
# docstring inherited
|
|
if not self._check_xy(renderer):
|
|
return Bbox.null()
|
|
return super().get_tightbbox(renderer)
|
|
|
|
|
|
_docstring.interpd.update(Annotation=Annotation.__init__.__doc__)
|