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
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: 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 or ~matplotlib.figure.SubFigure 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

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>, fontproperties=<UNSET>, fontsize=<UNSET>, fontstretch=<UNSET>, fontstyle=<UNSET>, fontvariant=<UNSET>, fontweight=<UNSET>, gid=<UNSET>, horizontalalignment=<UNSET>, in_layout=<UNSET>, label=<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>):
147        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 or ~matplotlib.figure.SubFigure 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