PySDM_examples.Arabas_et_al_2023.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: scalar or None
animated: bool
antialiased: bool
backgroundcolor: :mpltype:color
bbox: dict with properties for .patches.FancyBboxPatch
clip_box: unknown
clip_on: unknown
clip_path: unknown
color or c: :mpltype:color
figure: ~matplotlib.figure.Figure
fontfamily or family or fontname: {FONTNAME, 'serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'}
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
linespacing: float (multiple of font size)
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'}
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.
148 cls.set = lambda self, **kwargs: Artist.set(self, **kwargs)
Set multiple properties at once.
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: scalar or None
animated: bool
antialiased: bool
backgroundcolor: :mpltype:color
bbox: dict with properties for .patches.FancyBboxPatch
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
fontfamily or family or fontname: {FONTNAME, 'serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'}
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
linespacing: float (multiple of font size)
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'}
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