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
class CurvedText(matplotlib.text.Text):
  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.

CurvedText(x, y, text, axes, **kwargs)
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

def set_zorder(self, level):
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

def draw(self, renderer, *_, **__):
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.

def update_positions(self, renderer):
 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.

def set( self, *, agg_filter=<UNSET>, alpha=<UNSET>, animated=<UNSET>, antialiased=<UNSET>, backgroundcolor=<UNSET>, bbox=<UNSET>, clip_box=<UNSET>, clip_on=<UNSET>, clip_path=<UNSET>, color=<UNSET>, fontfamily=<UNSET>, fontfeatures=<UNSET>, fontproperties=<UNSET>, fontsize=<UNSET>, fontstretch=<UNSET>, fontstyle=<UNSET>, fontvariant=<UNSET>, fontweight=<UNSET>, gid=<UNSET>, horizontalalignment=<UNSET>, in_layout=<UNSET>, label=<UNSET>, language=<UNSET>, linespacing=<UNSET>, math_fontfamily=<UNSET>, mouseover=<UNSET>, multialignment=<UNSET>, parse_math=<UNSET>, path_effects=<UNSET>, picker=<UNSET>, position=<UNSET>, rasterized=<UNSET>, rotation=<UNSET>, rotation_mode=<UNSET>, sketch_params=<UNSET>, snap=<UNSET>, text=<UNSET>, transform=<UNSET>, transform_rotates_text=<UNSET>, url=<UNSET>, usetex=<UNSET>, verticalalignment=<UNSET>, visible=<UNSET>, wrap=<UNSET>, x=<UNSET>, y=<UNSET>, zorder=<UNSET>):
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