PySDM_examples.Arabas_et_al_2025.curved_text
1# https://stackoverflow.com/questions/19353576/curved-text-rendering-in-matplotlib 2import math 3 4import numpy as np 5from matplotlib import text as mtext 6 7 8class CurvedText(mtext.Text): 9 """ 10 A text object that follows an arbitrary curve. 11 """ 12 13 def __init__(self, x, y, text, axes, **kwargs): 14 super().__init__(x[0], y[0], " ", **kwargs) 15 16 axes.add_artist(self) 17 18 ##saving the curve: 19 self.__x = x 20 self.__y = y 21 self.__zorder = self.get_zorder() 22 23 ##creating the text objects 24 self.__Characters = [] 25 for c in text: 26 if c == " ": 27 ##make this an invisible 'a': 28 t = mtext.Text(0, 0, "a") 29 t.set_alpha(0.0) 30 else: 31 t = mtext.Text(0, 0, c, **kwargs) 32 33 # resetting unnecessary arguments 34 t.set_rotation(0) 35 t.set_zorder(self.__zorder + 1) 36 37 self.__Characters.append((c, t)) 38 axes.add_artist(t) 39 40 ##overloading some member functions, to assure correct functionality 41 ##on update 42 def set_zorder(self, level): 43 super().set_zorder(level) 44 self.__zorder = self.get_zorder() 45 for _, t in self.__Characters: 46 t.set_zorder(self.__zorder + 1) 47 48 def draw(self, renderer, *_, **__): 49 """ 50 Overload of the Text.draw() function. Do not do 51 do any drawing, but update the positions and rotation 52 angles of self.__Characters. 53 """ 54 self.update_positions(renderer) 55 56 def update_positions(self, renderer): 57 """ 58 Update positions and rotations of the individual text elements. 59 """ 60 61 # preparations 62 63 ##determining the aspect ratio: 64 ##from https://stackoverflow.com/a/42014041/2454357 65 66 ##data limits 67 xlim = self.axes.get_xlim() 68 ylim = self.axes.get_ylim() 69 ## Axis size on figure 70 figW, figH = self.axes.get_figure().get_size_inches() 71 ## Ratio of display units 72 _, _, w, h = self.axes.get_position().bounds 73 ##final aspect ratio 74 aspect = ((figW * w) / (figH * h)) * (ylim[1] - ylim[0]) / (xlim[1] - xlim[0]) 75 76 # points of the curve in figure coordinates: 77 x_fig, y_fig = ( 78 np.array(l) 79 for l in zip(*self.axes.transData.transform(list(zip(self.__x, self.__y)))) 80 ) 81 82 # point distances in figure coordinates 83 x_fig_dist = x_fig[1:] - x_fig[:-1] 84 y_fig_dist = y_fig[1:] - y_fig[:-1] 85 r_fig_dist = np.sqrt(x_fig_dist**2 + y_fig_dist**2) 86 87 # arc length in figure coordinates 88 l_fig = np.insert(np.cumsum(r_fig_dist), 0, 0) 89 90 # angles in figure coordinates 91 rads = np.arctan2((y_fig[1:] - y_fig[:-1]), (x_fig[1:] - x_fig[:-1])) 92 degs = np.rad2deg(rads) 93 94 rel_pos = 10 95 for c, t in self.__Characters: 96 # finding the width of c: 97 t.set_rotation(0) 98 t.set_va("center") 99 bbox1 = t.get_window_extent(renderer=renderer) 100 w = bbox1.width 101 h = bbox1.height 102 103 # ignore all letters that don't fit: 104 if rel_pos + w / 2 > l_fig[-1]: 105 t.set_alpha(0.0) 106 rel_pos += w 107 continue 108 109 if c != " ": 110 t.set_alpha(1.0) 111 112 # finding the two data points between which the horizontal 113 # center point of the character will be situated 114 # left and right indices: 115 il = np.where(rel_pos + w / 2 >= l_fig)[0][-1] 116 ir = np.where(rel_pos + w / 2 <= l_fig)[0][0] 117 118 # if we exactly hit a data point: 119 if ir == il: 120 ir += 1 121 122 # how much of the letter width was needed to find il: 123 used = l_fig[il] - rel_pos 124 rel_pos = l_fig[il] 125 126 # relative distance between il and ir where the center 127 # of the character will be 128 fraction = (w / 2 - used) / r_fig_dist[il] 129 130 ##setting the character position in data coordinates: 131 ##interpolate between the two points: 132 x = self.__x[il] + fraction * (self.__x[ir] - self.__x[il]) 133 y = self.__y[il] + fraction * (self.__y[ir] - self.__y[il]) 134 135 # getting the offset when setting correct vertical alignment 136 # in data coordinates 137 bbox2 = t.get_window_extent(renderer=renderer) 138 139 bbox1d = self.axes.transData.inverted().transform(bbox1) 140 bbox2d = self.axes.transData.inverted().transform(bbox2) 141 dr = np.array(bbox2d[0] - bbox1d[0]) 142 143 # the rotation/stretch matrix 144 rad = rads[il] 145 rot_mat = np.array( 146 [ 147 [math.cos(rad), math.sin(rad) * aspect], 148 [-math.sin(rad) / aspect, math.cos(rad)], 149 ] 150 ) 151 152 ##computing the offset vector of the rotated character 153 drp = np.dot(dr, rot_mat) 154 155 # setting final position and rotation: 156 t.set_position(np.array([x, y]) + drp) 157 t.set_rotation(degs[il]) 158 159 t.set_va("center") 160 t.set_ha("center") 161 162 # updating rel_pos to right edge of character 163 rel_pos += w - used
9class CurvedText(mtext.Text): 10 """ 11 A text object that follows an arbitrary curve. 12 """ 13 14 def __init__(self, x, y, text, axes, **kwargs): 15 super().__init__(x[0], y[0], " ", **kwargs) 16 17 axes.add_artist(self) 18 19 ##saving the curve: 20 self.__x = x 21 self.__y = y 22 self.__zorder = self.get_zorder() 23 24 ##creating the text objects 25 self.__Characters = [] 26 for c in text: 27 if c == " ": 28 ##make this an invisible 'a': 29 t = mtext.Text(0, 0, "a") 30 t.set_alpha(0.0) 31 else: 32 t = mtext.Text(0, 0, c, **kwargs) 33 34 # resetting unnecessary arguments 35 t.set_rotation(0) 36 t.set_zorder(self.__zorder + 1) 37 38 self.__Characters.append((c, t)) 39 axes.add_artist(t) 40 41 ##overloading some member functions, to assure correct functionality 42 ##on update 43 def set_zorder(self, level): 44 super().set_zorder(level) 45 self.__zorder = self.get_zorder() 46 for _, t in self.__Characters: 47 t.set_zorder(self.__zorder + 1) 48 49 def draw(self, renderer, *_, **__): 50 """ 51 Overload of the Text.draw() function. Do not do 52 do any drawing, but update the positions and rotation 53 angles of self.__Characters. 54 """ 55 self.update_positions(renderer) 56 57 def update_positions(self, renderer): 58 """ 59 Update positions and rotations of the individual text elements. 60 """ 61 62 # preparations 63 64 ##determining the aspect ratio: 65 ##from https://stackoverflow.com/a/42014041/2454357 66 67 ##data limits 68 xlim = self.axes.get_xlim() 69 ylim = self.axes.get_ylim() 70 ## Axis size on figure 71 figW, figH = self.axes.get_figure().get_size_inches() 72 ## Ratio of display units 73 _, _, w, h = self.axes.get_position().bounds 74 ##final aspect ratio 75 aspect = ((figW * w) / (figH * h)) * (ylim[1] - ylim[0]) / (xlim[1] - xlim[0]) 76 77 # points of the curve in figure coordinates: 78 x_fig, y_fig = ( 79 np.array(l) 80 for l in zip(*self.axes.transData.transform(list(zip(self.__x, self.__y)))) 81 ) 82 83 # point distances in figure coordinates 84 x_fig_dist = x_fig[1:] - x_fig[:-1] 85 y_fig_dist = y_fig[1:] - y_fig[:-1] 86 r_fig_dist = np.sqrt(x_fig_dist**2 + y_fig_dist**2) 87 88 # arc length in figure coordinates 89 l_fig = np.insert(np.cumsum(r_fig_dist), 0, 0) 90 91 # angles in figure coordinates 92 rads = np.arctan2((y_fig[1:] - y_fig[:-1]), (x_fig[1:] - x_fig[:-1])) 93 degs = np.rad2deg(rads) 94 95 rel_pos = 10 96 for c, t in self.__Characters: 97 # finding the width of c: 98 t.set_rotation(0) 99 t.set_va("center") 100 bbox1 = t.get_window_extent(renderer=renderer) 101 w = bbox1.width 102 h = bbox1.height 103 104 # ignore all letters that don't fit: 105 if rel_pos + w / 2 > l_fig[-1]: 106 t.set_alpha(0.0) 107 rel_pos += w 108 continue 109 110 if c != " ": 111 t.set_alpha(1.0) 112 113 # finding the two data points between which the horizontal 114 # center point of the character will be situated 115 # left and right indices: 116 il = np.where(rel_pos + w / 2 >= l_fig)[0][-1] 117 ir = np.where(rel_pos + w / 2 <= l_fig)[0][0] 118 119 # if we exactly hit a data point: 120 if ir == il: 121 ir += 1 122 123 # how much of the letter width was needed to find il: 124 used = l_fig[il] - rel_pos 125 rel_pos = l_fig[il] 126 127 # relative distance between il and ir where the center 128 # of the character will be 129 fraction = (w / 2 - used) / r_fig_dist[il] 130 131 ##setting the character position in data coordinates: 132 ##interpolate between the two points: 133 x = self.__x[il] + fraction * (self.__x[ir] - self.__x[il]) 134 y = self.__y[il] + fraction * (self.__y[ir] - self.__y[il]) 135 136 # getting the offset when setting correct vertical alignment 137 # in data coordinates 138 bbox2 = t.get_window_extent(renderer=renderer) 139 140 bbox1d = self.axes.transData.inverted().transform(bbox1) 141 bbox2d = self.axes.transData.inverted().transform(bbox2) 142 dr = np.array(bbox2d[0] - bbox1d[0]) 143 144 # the rotation/stretch matrix 145 rad = rads[il] 146 rot_mat = np.array( 147 [ 148 [math.cos(rad), math.sin(rad) * aspect], 149 [-math.sin(rad) / aspect, math.cos(rad)], 150 ] 151 ) 152 153 ##computing the offset vector of the rotated character 154 drp = np.dot(dr, rot_mat) 155 156 # setting final position and rotation: 157 t.set_position(np.array([x, y]) + drp) 158 t.set_rotation(degs[il]) 159 160 t.set_va("center") 161 t.set_ha("center") 162 163 # updating rel_pos to right edge of character 164 rel_pos += w - used
A text object that follows an arbitrary curve.
14 def __init__(self, x, y, text, axes, **kwargs): 15 super().__init__(x[0], y[0], " ", **kwargs) 16 17 axes.add_artist(self) 18 19 ##saving the curve: 20 self.__x = x 21 self.__y = y 22 self.__zorder = self.get_zorder() 23 24 ##creating the text objects 25 self.__Characters = [] 26 for c in text: 27 if c == " ": 28 ##make this an invisible 'a': 29 t = mtext.Text(0, 0, "a") 30 t.set_alpha(0.0) 31 else: 32 t = mtext.Text(0, 0, c, **kwargs) 33 34 # resetting unnecessary arguments 35 t.set_rotation(0) 36 t.set_zorder(self.__zorder + 1) 37 38 self.__Characters.append((c, t)) 39 axes.add_artist(t)
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:
Properties:
agg_filter: a filter function, which takes a (m, n, 3) float array and a dpi value, and returns a (m, n, 3) array and two offsets from the bottom left corner of the image
alpha: float or None
animated: bool
antialiased: bool
backgroundcolor: :mpltype:color
bbox: dict with properties for .FancyBboxPatch or None
clip_box: unknown
clip_on: unknown
clip_path: unknown
color or c: :mpltype:color
figure: ~matplotlib.figure.Figure or ~matplotlib.figure.SubFigure
fontfamily or family or fontname: {FONTNAME, 'serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'}
fontfeatures: list of str, or tuple of str, or None
fontproperties or font or font_properties: .font_manager.FontProperties or str or pathlib.Path
fontsize or size: float or {'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'}
fontstretch or stretch: {a numeric value in range 0-1000, 'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed', 'normal', 'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded'}
fontstyle or style: {'normal', 'italic', 'oblique'}
fontvariant or variant: {'normal', 'small-caps'}
fontweight or weight: {a numeric value in range 0-1000, 'ultralight', 'light', 'normal', 'regular', 'book', 'medium', 'roman', 'semibold', 'demibold', 'demi', 'bold', 'heavy', 'extra bold', 'black'}
gid: str
horizontalalignment or ha: {'left', 'center', 'right'}
in_layout: bool
label: object
language: str or None
linespacing: 'normal' or float, default: 'normal'
math_fontfamily: str
mouseover: bool
multialignment or ma: {'left', 'right', 'center'}
parse_math: bool
path_effects: list of .AbstractPathEffect
picker: None or bool or float or callable
position: (float, float)
rasterized: bool
rotation: float or {'vertical', 'horizontal'}
rotation_mode: {None, 'default', 'anchor', 'xtick', 'ytick'}
sketch_params: (scale: float, length: float, randomness: float)
snap: bool or None
text: object
transform: ~matplotlib.transforms.Transform
transform_rotates_text: bool
url: str
usetex: bool, default: :rc:text.usetex
verticalalignment or va: {'baseline', 'bottom', 'center', 'center_baseline', 'top'}
visible: bool
wrap: bool
x: float
y: float
zorder: float
43 def set_zorder(self, level): 44 super().set_zorder(level) 45 self.__zorder = self.get_zorder() 46 for _, t in self.__Characters: 47 t.set_zorder(self.__zorder + 1)
Set the zorder for the artist. Artists with lower zorder values are drawn first.
Parameters
level : float
49 def draw(self, renderer, *_, **__): 50 """ 51 Overload of the Text.draw() function. Do not do 52 do any drawing, but update the positions and rotation 53 angles of self.__Characters. 54 """ 55 self.update_positions(renderer)
Overload of the Text.draw() function. Do not do do any drawing, but update the positions and rotation angles of self.__Characters.
57 def update_positions(self, renderer): 58 """ 59 Update positions and rotations of the individual text elements. 60 """ 61 62 # preparations 63 64 ##determining the aspect ratio: 65 ##from https://stackoverflow.com/a/42014041/2454357 66 67 ##data limits 68 xlim = self.axes.get_xlim() 69 ylim = self.axes.get_ylim() 70 ## Axis size on figure 71 figW, figH = self.axes.get_figure().get_size_inches() 72 ## Ratio of display units 73 _, _, w, h = self.axes.get_position().bounds 74 ##final aspect ratio 75 aspect = ((figW * w) / (figH * h)) * (ylim[1] - ylim[0]) / (xlim[1] - xlim[0]) 76 77 # points of the curve in figure coordinates: 78 x_fig, y_fig = ( 79 np.array(l) 80 for l in zip(*self.axes.transData.transform(list(zip(self.__x, self.__y)))) 81 ) 82 83 # point distances in figure coordinates 84 x_fig_dist = x_fig[1:] - x_fig[:-1] 85 y_fig_dist = y_fig[1:] - y_fig[:-1] 86 r_fig_dist = np.sqrt(x_fig_dist**2 + y_fig_dist**2) 87 88 # arc length in figure coordinates 89 l_fig = np.insert(np.cumsum(r_fig_dist), 0, 0) 90 91 # angles in figure coordinates 92 rads = np.arctan2((y_fig[1:] - y_fig[:-1]), (x_fig[1:] - x_fig[:-1])) 93 degs = np.rad2deg(rads) 94 95 rel_pos = 10 96 for c, t in self.__Characters: 97 # finding the width of c: 98 t.set_rotation(0) 99 t.set_va("center") 100 bbox1 = t.get_window_extent(renderer=renderer) 101 w = bbox1.width 102 h = bbox1.height 103 104 # ignore all letters that don't fit: 105 if rel_pos + w / 2 > l_fig[-1]: 106 t.set_alpha(0.0) 107 rel_pos += w 108 continue 109 110 if c != " ": 111 t.set_alpha(1.0) 112 113 # finding the two data points between which the horizontal 114 # center point of the character will be situated 115 # left and right indices: 116 il = np.where(rel_pos + w / 2 >= l_fig)[0][-1] 117 ir = np.where(rel_pos + w / 2 <= l_fig)[0][0] 118 119 # if we exactly hit a data point: 120 if ir == il: 121 ir += 1 122 123 # how much of the letter width was needed to find il: 124 used = l_fig[il] - rel_pos 125 rel_pos = l_fig[il] 126 127 # relative distance between il and ir where the center 128 # of the character will be 129 fraction = (w / 2 - used) / r_fig_dist[il] 130 131 ##setting the character position in data coordinates: 132 ##interpolate between the two points: 133 x = self.__x[il] + fraction * (self.__x[ir] - self.__x[il]) 134 y = self.__y[il] + fraction * (self.__y[ir] - self.__y[il]) 135 136 # getting the offset when setting correct vertical alignment 137 # in data coordinates 138 bbox2 = t.get_window_extent(renderer=renderer) 139 140 bbox1d = self.axes.transData.inverted().transform(bbox1) 141 bbox2d = self.axes.transData.inverted().transform(bbox2) 142 dr = np.array(bbox2d[0] - bbox1d[0]) 143 144 # the rotation/stretch matrix 145 rad = rads[il] 146 rot_mat = np.array( 147 [ 148 [math.cos(rad), math.sin(rad) * aspect], 149 [-math.sin(rad) / aspect, math.cos(rad)], 150 ] 151 ) 152 153 ##computing the offset vector of the rotated character 154 drp = np.dot(dr, rot_mat) 155 156 # setting final position and rotation: 157 t.set_position(np.array([x, y]) + drp) 158 t.set_rotation(degs[il]) 159 160 t.set_va("center") 161 t.set_ha("center") 162 163 # updating rel_pos to right edge of character 164 rel_pos += w - used
Update positions and rotations of the individual text elements.
141 cls.set = lambda self, **kwargs: Artist.set(self, **kwargs)
Set multiple properties at once.
::
a.set(a=A, b=B, c=C)
is equivalent to ::
a.set_a(A)
a.set_b(B)
a.set_c(C)
In addition to the full property names, aliases are also supported, e.g.
set(lw=2) is equivalent to set(linewidth=2), but it is an error
to pass both simultaneously.
The order of the individual setter calls matches the order of parameters
in set(). However, most properties do not depend on each other so
that order is rarely relevant.
Supported properties are
Properties:
agg_filter: a filter function, which takes a (m, n, 3) float array and a dpi value, and returns a (m, n, 3) array and two offsets from the bottom left corner of the image
alpha: float or None
animated: bool
antialiased: bool
backgroundcolor: :mpltype:color
bbox: dict with properties for .FancyBboxPatch or None
clip_box: ~matplotlib.transforms.BboxBase or None
clip_on: bool
clip_path: Patch or (Path, Transform) or None
color or c: :mpltype:color
figure: ~matplotlib.figure.Figure or ~matplotlib.figure.SubFigure
fontfamily or family or fontname: {FONTNAME, 'serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'}
fontfeatures: list of str, or tuple of str, or None
fontproperties or font or font_properties: .font_manager.FontProperties or str or pathlib.Path
fontsize or size: float or {'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'}
fontstretch or stretch: {a numeric value in range 0-1000, 'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed', 'normal', 'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded'}
fontstyle or style: {'normal', 'italic', 'oblique'}
fontvariant or variant: {'normal', 'small-caps'}
fontweight or weight: {a numeric value in range 0-1000, 'ultralight', 'light', 'normal', 'regular', 'book', 'medium', 'roman', 'semibold', 'demibold', 'demi', 'bold', 'heavy', 'extra bold', 'black'}
gid: str
horizontalalignment or ha: {'left', 'center', 'right'}
in_layout: bool
label: object
language: str or None
linespacing: 'normal' or float, default: 'normal'
math_fontfamily: str
mouseover: bool
multialignment or ma: {'left', 'right', 'center'}
parse_math: bool
path_effects: list of .AbstractPathEffect
picker: None or bool or float or callable
position: (float, float)
rasterized: bool
rotation: float or {'vertical', 'horizontal'}
rotation_mode: {None, 'default', 'anchor', 'xtick', 'ytick'}
sketch_params: (scale: float, length: float, randomness: float)
snap: bool or None
text: object
transform: ~matplotlib.transforms.Transform
transform_rotates_text: bool
url: str
usetex: bool, default: :rc:text.usetex
verticalalignment or va: {'baseline', 'bottom', 'center', 'center_baseline', 'top'}
visible: bool
wrap: bool
x: float
y: float
zorder: unknown