curved-text 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph J. Thiebes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: curved-text
3
+ Version: 0.1.0
4
+ Summary: Draw text along an arbitrary curve in matplotlib, with arc-length positioning and a perpendicular offset.
5
+ Author-email: Joseph Thiebes <joseph@thiebes.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/thiebes/curved-text
8
+ Project-URL: Source, https://github.com/thiebes/curved-text
9
+ Project-URL: Issues, https://github.com/thiebes/curved-text/issues
10
+ Project-URL: Changelog, https://github.com/thiebes/curved-text/blob/main/CHANGELOG.md
11
+ Keywords: matplotlib,text,annotation,label,curve,visualization
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Framework :: Matplotlib
20
+ Classifier: Topic :: Scientific/Engineering :: Visualization
21
+ Classifier: Intended Audience :: Science/Research
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: matplotlib>=3.5
26
+ Requires-Dist: numpy>=1.20
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7.0; extra == "test"
29
+ Provides-Extra: examples
30
+ Requires-Dist: seaborn>=0.11; extra == "examples"
31
+ Requires-Dist: pandas>=1.3; extra == "examples"
32
+ Dynamic: license-file
33
+
34
+ # curved-text
35
+
36
+ [![CI](https://github.com/thiebes/curved-text/actions/workflows/ci.yml/badge.svg)](https://github.com/thiebes/curved-text/actions/workflows/ci.yml)
37
+
38
+ Draw text that follows an arbitrary curve in [matplotlib](https://matplotlib.org/).
39
+
40
+ ![Direct labeling versus a legend](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/01_direct_labeling.png)
41
+
42
+ Label curves along their own paths instead of in a legend, so the eye never
43
+ leaves the data to decode a colour key.
44
+
45
+ Each character is placed in display coordinates and rotated to the local tangent
46
+ of the curve, recomputed on every draw, so the label keeps following the curve
47
+ through layout, resizing, and interactive panning or zooming. Placement is
48
+ controlled by three independent parameters:
49
+
50
+ - `pos` -- where the label is anchored along the curve, as a fraction of arc
51
+ length (`0.0` = first point, `1.0` = last).
52
+ - `anchor` -- which part of the label lands at `pos`: `"start"`, `"center"`, or
53
+ `"end"`.
54
+ - `offset` -- a perpendicular shift off the curve, in typographic points, along
55
+ the normal of the label's chord (positive is above a left-to-right curve).
56
+
57
+ A label that overruns either end of the curve is not clipped: the curve is
58
+ extended along its end tangent and the overrunning glyphs sit on that straight
59
+ extension.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install curved-text
65
+ ```
66
+
67
+ Or, from a clone, an editable install:
68
+
69
+ ```bash
70
+ pip install -e .
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ```python
76
+ import numpy as np
77
+ import matplotlib.pyplot as plt
78
+ from curved_text import curved_text
79
+
80
+ x = np.linspace(0, 2 * np.pi, 400)
81
+ y = np.sin(x)
82
+
83
+ fig, ax = plt.subplots()
84
+ ax.plot(x, y)
85
+ curved_text(ax, x, y, "text that follows the curve",
86
+ pos=0.5, anchor="center", offset=6.0, color="C3")
87
+ plt.show()
88
+ ```
89
+
90
+ ![A label following a sine wave](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/02_sine_hello.png)
91
+
92
+ More worked examples -- the three placement controls, overrun behaviour, and
93
+ integration with seaborn and pandas -- are in [examples/](examples/README.md).
94
+
95
+ The object-oriented form is also available:
96
+
97
+ ```python
98
+ from curved_text import CurvedText
99
+
100
+ CurvedText(x, y, "along the curve", ax, pos=0.2, anchor="start", offset=4.0)
101
+ ```
102
+
103
+ Note the axes argument position: the `CurvedText` class takes it after `x, y,
104
+ text` (matching `matplotlib.text.Text`), while the `curved_text` function takes it
105
+ first (matching matplotlib's axes-first helper functions).
106
+
107
+ Any extra keyword arguments (`color`, `fontsize`, `alpha`, `fontfamily`, ...) are
108
+ passed through to each character's `matplotlib.text.Text`.
109
+
110
+ ## Works with seaborn, pandas, and other matplotlib-backed libraries
111
+
112
+ `curved_text` needs a `matplotlib.axes.Axes`, so it works with any library that
113
+ draws on matplotlib. seaborn's axes-level functions return an `Axes`, its
114
+ figure-level functions expose one through `.axes`, and `pandas` `DataFrame.plot`
115
+ returns an `Axes` as well. Pass that axes in directly:
116
+
117
+ ```python
118
+ import seaborn as sns
119
+
120
+ ax = sns.lineplot(data=df, x="x", y="y")
121
+ curved_text(ax, df["x"], df["y"], "along the curve",
122
+ pos=0.5, anchor="center", offset=6.0)
123
+ ```
124
+
125
+ ## Notes
126
+
127
+ - The curve `(x, y)` should be ordered along the curve (monotonic in arc length)
128
+ and have at least two points.
129
+ - Arc length and the offset are computed in display space, so spacing and the
130
+ offset are correct at any DPI and figure size.
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,101 @@
1
+ # curved-text
2
+
3
+ [![CI](https://github.com/thiebes/curved-text/actions/workflows/ci.yml/badge.svg)](https://github.com/thiebes/curved-text/actions/workflows/ci.yml)
4
+
5
+ Draw text that follows an arbitrary curve in [matplotlib](https://matplotlib.org/).
6
+
7
+ ![Direct labeling versus a legend](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/01_direct_labeling.png)
8
+
9
+ Label curves along their own paths instead of in a legend, so the eye never
10
+ leaves the data to decode a colour key.
11
+
12
+ Each character is placed in display coordinates and rotated to the local tangent
13
+ of the curve, recomputed on every draw, so the label keeps following the curve
14
+ through layout, resizing, and interactive panning or zooming. Placement is
15
+ controlled by three independent parameters:
16
+
17
+ - `pos` -- where the label is anchored along the curve, as a fraction of arc
18
+ length (`0.0` = first point, `1.0` = last).
19
+ - `anchor` -- which part of the label lands at `pos`: `"start"`, `"center"`, or
20
+ `"end"`.
21
+ - `offset` -- a perpendicular shift off the curve, in typographic points, along
22
+ the normal of the label's chord (positive is above a left-to-right curve).
23
+
24
+ A label that overruns either end of the curve is not clipped: the curve is
25
+ extended along its end tangent and the overrunning glyphs sit on that straight
26
+ extension.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install curved-text
32
+ ```
33
+
34
+ Or, from a clone, an editable install:
35
+
36
+ ```bash
37
+ pip install -e .
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ import numpy as np
44
+ import matplotlib.pyplot as plt
45
+ from curved_text import curved_text
46
+
47
+ x = np.linspace(0, 2 * np.pi, 400)
48
+ y = np.sin(x)
49
+
50
+ fig, ax = plt.subplots()
51
+ ax.plot(x, y)
52
+ curved_text(ax, x, y, "text that follows the curve",
53
+ pos=0.5, anchor="center", offset=6.0, color="C3")
54
+ plt.show()
55
+ ```
56
+
57
+ ![A label following a sine wave](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/02_sine_hello.png)
58
+
59
+ More worked examples -- the three placement controls, overrun behaviour, and
60
+ integration with seaborn and pandas -- are in [examples/](examples/README.md).
61
+
62
+ The object-oriented form is also available:
63
+
64
+ ```python
65
+ from curved_text import CurvedText
66
+
67
+ CurvedText(x, y, "along the curve", ax, pos=0.2, anchor="start", offset=4.0)
68
+ ```
69
+
70
+ Note the axes argument position: the `CurvedText` class takes it after `x, y,
71
+ text` (matching `matplotlib.text.Text`), while the `curved_text` function takes it
72
+ first (matching matplotlib's axes-first helper functions).
73
+
74
+ Any extra keyword arguments (`color`, `fontsize`, `alpha`, `fontfamily`, ...) are
75
+ passed through to each character's `matplotlib.text.Text`.
76
+
77
+ ## Works with seaborn, pandas, and other matplotlib-backed libraries
78
+
79
+ `curved_text` needs a `matplotlib.axes.Axes`, so it works with any library that
80
+ draws on matplotlib. seaborn's axes-level functions return an `Axes`, its
81
+ figure-level functions expose one through `.axes`, and `pandas` `DataFrame.plot`
82
+ returns an `Axes` as well. Pass that axes in directly:
83
+
84
+ ```python
85
+ import seaborn as sns
86
+
87
+ ax = sns.lineplot(data=df, x="x", y="y")
88
+ curved_text(ax, df["x"], df["y"], "along the curve",
89
+ pos=0.5, anchor="center", offset=6.0)
90
+ ```
91
+
92
+ ## Notes
93
+
94
+ - The curve `(x, y)` should be ordered along the curve (monotonic in arc length)
95
+ and have at least two points.
96
+ - Arc length and the offset are computed in display space, so spacing and the
97
+ offset are correct at any DPI and figure size.
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "curved-text"
7
+ version = "0.1.0"
8
+ description = "Draw text along an arbitrary curve in matplotlib, with arc-length positioning and a perpendicular offset."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Joseph Thiebes", email = "joseph@thiebes.org" }]
14
+ keywords = ["matplotlib", "text", "annotation", "label", "curve", "visualization"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Framework :: Matplotlib",
24
+ "Topic :: Scientific/Engineering :: Visualization",
25
+ "Intended Audience :: Science/Research",
26
+ ]
27
+
28
+ # Library dependencies use compatible lower bounds; a consuming application pins
29
+ # exact versions in its own lockfile.
30
+ dependencies = [
31
+ "matplotlib>=3.5",
32
+ "numpy>=1.20",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ test = ["pytest>=7.0"]
37
+ # Extra renderers used only by the integration example (matplotlib is already a
38
+ # core dependency). Lower bounds, like the rest of the project.
39
+ examples = [
40
+ "seaborn>=0.11",
41
+ "pandas>=1.3",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/thiebes/curved-text"
46
+ Source = "https://github.com/thiebes/curved-text"
47
+ Issues = "https://github.com/thiebes/curved-text/issues"
48
+ Changelog = "https://github.com/thiebes/curved-text/blob/main/CHANGELOG.md"
49
+
50
+ [tool.setuptools.packages.find]
51
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ """curved-text: draw text along an arbitrary curve in matplotlib."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ from ._core import CurvedText, curved_text
5
+
6
+ __all__ = ["CurvedText", "curved_text"]
7
+
8
+ try:
9
+ __version__ = version("curved-text")
10
+ except PackageNotFoundError: # running from a source tree without an install
11
+ __version__ = "0.0.0"
@@ -0,0 +1,176 @@
1
+ """Draw text along an arbitrary curve in a matplotlib Axes."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import matplotlib.text as mtext
7
+ import numpy as np
8
+
9
+ if TYPE_CHECKING:
10
+ from matplotlib.axes import Axes
11
+ from numpy.typing import ArrayLike
12
+
13
+ __all__ = ["CurvedText", "curved_text"]
14
+
15
+ _ANCHORS = ("start", "center", "end")
16
+
17
+
18
+ class CurvedText(mtext.Text):
19
+ """A string drawn along an (x, y) curve, one character at a time.
20
+
21
+ Each character is an independent :class:`matplotlib.text.Text`, centered
22
+ (``ha="center"``, ``va="center"``) on its own arc-length midpoint, placed in
23
+ display coordinates and rotated to the local tangent of the curve. The layout
24
+ is recomputed on every draw, so the label keeps following the curve through
25
+ figure layout, resizing, and interactive panning or zooming.
26
+
27
+ Placement has three independent controls:
28
+
29
+ ``pos``
30
+ Where the label is anchored along the curve, as a fraction of the curve's
31
+ arc length: ``0.0`` is the first point, ``1.0`` is the last.
32
+ ``anchor``
33
+ Which part of the label lands at ``pos``: ``"start"``, ``"center"``, or
34
+ ``"end"``.
35
+ ``offset``
36
+ A perpendicular shift off the curve, in typographic points, along the
37
+ normal of the label's chord (the line from its first to its last glyph).
38
+ Positive is to the left of the direction of travel, which is visually
39
+ above a left-to-right curve.
40
+
41
+ A label that overruns either end of the curve -- because of ``pos`` and
42
+ ``anchor`` -- is not clipped. The curve is extended along its end tangent and
43
+ the overrunning glyphs are placed on that straight extension.
44
+
45
+ Parameters
46
+ ----------
47
+ x, y : array-like
48
+ The curve in data coordinates: 1-D, equal length, at least two points,
49
+ finite, and ordered along the curve.
50
+ text : str
51
+ The string to draw.
52
+ axes : matplotlib.axes.Axes
53
+ The axes to draw into.
54
+ pos : float, default 0.5
55
+ Arc-length fraction in ``[0, 1]`` for the anchor point.
56
+ anchor : {"start", "center", "end"}, default "center"
57
+ Which part of the label sits at ``pos``.
58
+ offset : float, default 0.0
59
+ Perpendicular offset off the curve, in points.
60
+ **kwargs
61
+ Passed to each per-character :class:`~matplotlib.text.Text` (for example
62
+ ``color``, ``fontsize``, ``alpha``, ``fontfamily``).
63
+ """
64
+
65
+ def __init__(self, x: ArrayLike, y: ArrayLike, text: str, axes: Axes, *,
66
+ pos: float = 0.5, anchor: str = "center", offset: float = 0.0,
67
+ **kwargs: Any) -> None:
68
+ if anchor not in _ANCHORS:
69
+ raise ValueError(f"anchor must be one of {_ANCHORS}, got {anchor!r}")
70
+ x = np.asarray(x, dtype=float)
71
+ y = np.asarray(y, dtype=float)
72
+ if x.ndim != 1 or x.shape != y.shape or x.size < 2:
73
+ raise ValueError("x and y must be 1-D arrays of equal length >= 2")
74
+ if not (np.isfinite(x).all() and np.isfinite(y).all()):
75
+ raise ValueError("x and y must contain only finite values")
76
+ super().__init__(float(x[0]), float(y[0]), " ", **kwargs)
77
+ self._cx = x
78
+ self._cy = y
79
+ self._pos = float(pos)
80
+ self._anchor = anchor
81
+ self._offset = float(offset)
82
+ axes.add_artist(self)
83
+ self._chars: list[mtext.Text] = []
84
+ for ch in text:
85
+ t = mtext.Text(0.0, 0.0, " " if ch == " " else ch, **kwargs)
86
+ t.set_ha("center")
87
+ t.set_va("center")
88
+ axes.add_artist(t)
89
+ self._chars.append(t)
90
+
91
+ def set_zorder(self, zorder) -> None:
92
+ # Keep the glyphs one level above the container so they sit on top of it.
93
+ # ``super().__init__`` may set the zorder before ``_chars`` exists, so
94
+ # guard against running during base-class construction.
95
+ super().set_zorder(zorder)
96
+ for t in getattr(self, "_chars", ()):
97
+ t.set_zorder(self.get_zorder() + 1)
98
+
99
+ def remove(self) -> None:
100
+ # The glyphs are independent artists on the axes; remove them with the
101
+ # container so removal does not leave them behind as orphans.
102
+ for t in self._chars:
103
+ t.remove()
104
+ self._chars = []
105
+ super().remove()
106
+
107
+ def draw(self, renderer, *args, **kwargs) -> None:
108
+ if not self._chars:
109
+ return
110
+ axes = self.axes
111
+ # Work in display pixels: project the curve, accumulate cumulative arc
112
+ # length along it, and the tangent angle of each segment.
113
+ pts = axes.transData.transform(np.column_stack([self._cx, self._cy]))
114
+ xf, yf = pts[:, 0], pts[:, 1]
115
+ arc = np.insert(np.cumsum(np.hypot(np.diff(xf), np.diff(yf))), 0, 0.0)
116
+ if not np.isfinite(arc[-1]) or arc[-1] <= 0.0:
117
+ return
118
+ rads = np.arctan2(np.diff(yf), np.diff(xf))
119
+ inv = axes.transData.inverted()
120
+
121
+ # Reset any rotation left by the previous draw before measuring, so each
122
+ # width is the unrotated advance, not the wider rotated bounding box.
123
+ for t in self._chars:
124
+ t.set_rotation(0)
125
+ widths = [t.get_window_extent(renderer=renderer).width for t in self._chars]
126
+ total = float(sum(widths))
127
+
128
+ def _point(s):
129
+ # Position at arc length ``s``. ``i`` is the segment it falls in, which
130
+ # the caller also uses to read the local tangent ``rads[i]``. Clipping
131
+ # the index extrapolates past either end along the terminal segment.
132
+ i = int(np.clip(np.searchsorted(arc, s) - 1, 0, len(arc) - 2))
133
+ d = arc[i + 1] - arc[i]
134
+ f = (s - arc[i]) / d if d else 0.0
135
+ return i, xf[i] + f * (xf[i + 1] - xf[i]), yf[i] + f * (yf[i + 1] - yf[i])
136
+
137
+ s0 = self._pos * arc[-1]
138
+ if self._anchor == "center":
139
+ cursor = s0 - total / 2.0
140
+ elif self._anchor == "end":
141
+ cursor = s0 - total
142
+ else:
143
+ cursor = s0
144
+
145
+ # Offset the whole label along the normal of its chord (first to last
146
+ # glyph); ``_point`` extrapolates past the curve ends along their tangents.
147
+ _, x0, y0 = _point(cursor)
148
+ _, x1, y1 = _point(cursor + total)
149
+ dx, dy = x1 - x0, y1 - y0
150
+ norm = float(np.hypot(dx, dy))
151
+ nx, ny = (-dy / norm, dx / norm) if norm else (0.0, 1.0)
152
+ scale = self._offset * renderer.points_to_pixels(1.0)
153
+ ox, oy = nx * scale, ny * scale
154
+
155
+ # ``cursor`` walks the label's left edge along the arc; each glyph is
156
+ # centered on its own midpoint and rotated to the local tangent.
157
+ for t, w in zip(self._chars, widths):
158
+ i, px, py = _point(cursor + w / 2.0)
159
+ t.set_position(inv.transform((px + ox, py + oy)))
160
+ t.set_rotation(np.degrees(rads[i]))
161
+ t.set_visible(True)
162
+ cursor += w
163
+
164
+
165
+ def curved_text(ax: Axes, x: ArrayLike, y: ArrayLike, text: str, *,
166
+ pos: float = 0.5, anchor: str = "center", offset: float = 0.0,
167
+ **kwargs: Any) -> CurvedText:
168
+ """Draw ``text`` along the curve ``(x, y)`` on ``ax`` and return the artist.
169
+
170
+ Thin convenience wrapper around :class:`CurvedText`; see it for the meaning of
171
+ ``pos``, ``anchor``, and ``offset``. The axes is the first argument here,
172
+ matching matplotlib's axes-first helper functions, whereas :class:`CurvedText`
173
+ takes it after ``x, y, text`` to match :class:`matplotlib.text.Text`.
174
+ """
175
+ return CurvedText(x, y, text, ax, pos=pos, anchor=anchor, offset=offset,
176
+ **kwargs)
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: curved-text
3
+ Version: 0.1.0
4
+ Summary: Draw text along an arbitrary curve in matplotlib, with arc-length positioning and a perpendicular offset.
5
+ Author-email: Joseph Thiebes <joseph@thiebes.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/thiebes/curved-text
8
+ Project-URL: Source, https://github.com/thiebes/curved-text
9
+ Project-URL: Issues, https://github.com/thiebes/curved-text/issues
10
+ Project-URL: Changelog, https://github.com/thiebes/curved-text/blob/main/CHANGELOG.md
11
+ Keywords: matplotlib,text,annotation,label,curve,visualization
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Framework :: Matplotlib
20
+ Classifier: Topic :: Scientific/Engineering :: Visualization
21
+ Classifier: Intended Audience :: Science/Research
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: matplotlib>=3.5
26
+ Requires-Dist: numpy>=1.20
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7.0; extra == "test"
29
+ Provides-Extra: examples
30
+ Requires-Dist: seaborn>=0.11; extra == "examples"
31
+ Requires-Dist: pandas>=1.3; extra == "examples"
32
+ Dynamic: license-file
33
+
34
+ # curved-text
35
+
36
+ [![CI](https://github.com/thiebes/curved-text/actions/workflows/ci.yml/badge.svg)](https://github.com/thiebes/curved-text/actions/workflows/ci.yml)
37
+
38
+ Draw text that follows an arbitrary curve in [matplotlib](https://matplotlib.org/).
39
+
40
+ ![Direct labeling versus a legend](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/01_direct_labeling.png)
41
+
42
+ Label curves along their own paths instead of in a legend, so the eye never
43
+ leaves the data to decode a colour key.
44
+
45
+ Each character is placed in display coordinates and rotated to the local tangent
46
+ of the curve, recomputed on every draw, so the label keeps following the curve
47
+ through layout, resizing, and interactive panning or zooming. Placement is
48
+ controlled by three independent parameters:
49
+
50
+ - `pos` -- where the label is anchored along the curve, as a fraction of arc
51
+ length (`0.0` = first point, `1.0` = last).
52
+ - `anchor` -- which part of the label lands at `pos`: `"start"`, `"center"`, or
53
+ `"end"`.
54
+ - `offset` -- a perpendicular shift off the curve, in typographic points, along
55
+ the normal of the label's chord (positive is above a left-to-right curve).
56
+
57
+ A label that overruns either end of the curve is not clipped: the curve is
58
+ extended along its end tangent and the overrunning glyphs sit on that straight
59
+ extension.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install curved-text
65
+ ```
66
+
67
+ Or, from a clone, an editable install:
68
+
69
+ ```bash
70
+ pip install -e .
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ```python
76
+ import numpy as np
77
+ import matplotlib.pyplot as plt
78
+ from curved_text import curved_text
79
+
80
+ x = np.linspace(0, 2 * np.pi, 400)
81
+ y = np.sin(x)
82
+
83
+ fig, ax = plt.subplots()
84
+ ax.plot(x, y)
85
+ curved_text(ax, x, y, "text that follows the curve",
86
+ pos=0.5, anchor="center", offset=6.0, color="C3")
87
+ plt.show()
88
+ ```
89
+
90
+ ![A label following a sine wave](https://raw.githubusercontent.com/thiebes/curved-text/main/examples/images/02_sine_hello.png)
91
+
92
+ More worked examples -- the three placement controls, overrun behaviour, and
93
+ integration with seaborn and pandas -- are in [examples/](examples/README.md).
94
+
95
+ The object-oriented form is also available:
96
+
97
+ ```python
98
+ from curved_text import CurvedText
99
+
100
+ CurvedText(x, y, "along the curve", ax, pos=0.2, anchor="start", offset=4.0)
101
+ ```
102
+
103
+ Note the axes argument position: the `CurvedText` class takes it after `x, y,
104
+ text` (matching `matplotlib.text.Text`), while the `curved_text` function takes it
105
+ first (matching matplotlib's axes-first helper functions).
106
+
107
+ Any extra keyword arguments (`color`, `fontsize`, `alpha`, `fontfamily`, ...) are
108
+ passed through to each character's `matplotlib.text.Text`.
109
+
110
+ ## Works with seaborn, pandas, and other matplotlib-backed libraries
111
+
112
+ `curved_text` needs a `matplotlib.axes.Axes`, so it works with any library that
113
+ draws on matplotlib. seaborn's axes-level functions return an `Axes`, its
114
+ figure-level functions expose one through `.axes`, and `pandas` `DataFrame.plot`
115
+ returns an `Axes` as well. Pass that axes in directly:
116
+
117
+ ```python
118
+ import seaborn as sns
119
+
120
+ ax = sns.lineplot(data=df, x="x", y="y")
121
+ curved_text(ax, df["x"], df["y"], "along the curve",
122
+ pos=0.5, anchor="center", offset=6.0)
123
+ ```
124
+
125
+ ## Notes
126
+
127
+ - The curve `(x, y)` should be ordered along the curve (monotonic in arc length)
128
+ and have at least two points.
129
+ - Arc length and the offset are computed in display space, so spacing and the
130
+ offset are correct at any DPI and figure size.
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/curved_text/__init__.py
5
+ src/curved_text/_core.py
6
+ src/curved_text.egg-info/PKG-INFO
7
+ src/curved_text.egg-info/SOURCES.txt
8
+ src/curved_text.egg-info/dependency_links.txt
9
+ src/curved_text.egg-info/requires.txt
10
+ src/curved_text.egg-info/top_level.txt
11
+ tests/test_core.py
@@ -0,0 +1,9 @@
1
+ matplotlib>=3.5
2
+ numpy>=1.20
3
+
4
+ [examples]
5
+ seaborn>=0.11
6
+ pandas>=1.3
7
+
8
+ [test]
9
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ curved_text
@@ -0,0 +1,188 @@
1
+ """Smoke and behaviour tests for curved_text.
2
+
3
+ The Agg backend is selected in conftest.py before pyplot is imported.
4
+ """
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+ import pytest
8
+
9
+ from curved_text import CurvedText, curved_text
10
+
11
+
12
+ def _draw(fig):
13
+ """Force a draw so CurvedText positions its glyphs."""
14
+ fig.canvas.draw()
15
+
16
+
17
+ def test_places_one_artist_per_character():
18
+ fig, ax = plt.subplots()
19
+ x = np.linspace(0, 1, 50)
20
+ ct = curved_text(ax, x, np.sin(x), "abc", pos=0.5, anchor="center")
21
+ assert len(ct._chars) == 3
22
+ _draw(fig)
23
+ assert all(t.get_visible() for t in ct._chars)
24
+ plt.close(fig)
25
+
26
+
27
+ def test_anchor_shifts_label_along_curve():
28
+ # A straight horizontal curve so arc length maps to x directly.
29
+ fig, ax = plt.subplots()
30
+ ax.set_xlim(0, 10)
31
+ ax.set_ylim(0, 1)
32
+ x = np.linspace(0, 10, 100)
33
+ y = np.full_like(x, 0.5)
34
+ start = curved_text(ax, x, y, "word", pos=0.5, anchor="start")
35
+ end = curved_text(ax, x, y, "word", pos=0.5, anchor="end")
36
+ _draw(fig)
37
+ # "start" puts the text to the right of "end" at the same pos.
38
+ sx = np.mean([t.get_position()[0] for t in start._chars])
39
+ ex = np.mean([t.get_position()[0] for t in end._chars])
40
+ assert sx > ex
41
+ plt.close(fig)
42
+
43
+
44
+ def test_offset_moves_perpendicular():
45
+ fig, ax = plt.subplots()
46
+ ax.set_xlim(0, 10)
47
+ ax.set_ylim(0, 10)
48
+ x = np.linspace(0, 10, 100)
49
+ y = np.full_like(x, 5.0)
50
+ flat = curved_text(ax, x, y, "word", pos=0.5, anchor="center", offset=0.0)
51
+ lifted = curved_text(ax, x, y, "word", pos=0.5, anchor="center", offset=10.0)
52
+ _draw(fig)
53
+ fy = np.mean([t.get_position()[1] for t in flat._chars])
54
+ ly = np.mean([t.get_position()[1] for t in lifted._chars])
55
+ # Positive offset is above a left-to-right curve.
56
+ assert ly > fy
57
+ plt.close(fig)
58
+
59
+
60
+ def test_offset_is_dpi_invariant_in_points():
61
+ # The offset is specified in typographic points, so the same point value must
62
+ # produce the same data-space displacement regardless of figure DPI.
63
+ def displacement(dpi):
64
+ fig, ax = plt.subplots(dpi=dpi)
65
+ ax.set_xlim(0, 10)
66
+ ax.set_ylim(0, 10)
67
+ x = np.linspace(0, 10, 100)
68
+ y = np.full_like(x, 5.0)
69
+ flat = curved_text(ax, x, y, "word", pos=0.5, anchor="center", offset=0.0)
70
+ lifted = curved_text(ax, x, y, "word", pos=0.5, anchor="center",
71
+ offset=10.0)
72
+ _draw(fig)
73
+ fy = np.mean([t.get_position()[1] for t in flat._chars])
74
+ ly = np.mean([t.get_position()[1] for t in lifted._chars])
75
+ plt.close(fig)
76
+ return ly - fy
77
+
78
+ assert displacement(72) == pytest.approx(displacement(144), rel=0.02)
79
+
80
+
81
+ def test_overrun_is_not_clipped():
82
+ # Anchor the start of a long label past the right end of a short curve; the
83
+ # overrunning glyphs should ride the tangent extension, all still visible.
84
+ fig, ax = plt.subplots()
85
+ x = np.linspace(0, 1, 20)
86
+ ct = curved_text(ax, x, np.zeros_like(x), "a long label", pos=1.0,
87
+ anchor="start")
88
+ _draw(fig)
89
+ assert all(t.get_visible() for t in ct._chars)
90
+ plt.close(fig)
91
+
92
+
93
+ def test_left_overrun_is_not_clipped():
94
+ # The symmetric case: anchor the end of a long label before the left end of a
95
+ # short curve so the label rides the left tangent extension.
96
+ fig, ax = plt.subplots()
97
+ x = np.linspace(0, 1, 20)
98
+ ct = curved_text(ax, x, np.zeros_like(x), "a long label", pos=0.0,
99
+ anchor="end")
100
+ _draw(fig)
101
+ assert all(t.get_visible() for t in ct._chars)
102
+ plt.close(fig)
103
+
104
+
105
+ def test_degenerate_curve_does_not_raise():
106
+ # A curve whose points are all identical has zero arc length; drawing must
107
+ # short-circuit cleanly rather than raising.
108
+ fig, ax = plt.subplots()
109
+ x = np.full(10, 3.0)
110
+ y = np.full(10, 3.0)
111
+ ct = curved_text(ax, x, y, "abc", pos=0.5, anchor="center")
112
+ _draw(fig)
113
+ assert len(ct._chars) == 3
114
+ plt.close(fig)
115
+
116
+
117
+ def test_wrapper_matches_class():
118
+ # curved_text is a thin wrapper; for the same inputs the two forms must place
119
+ # glyphs identically despite the different argument order.
120
+ fig, ax = plt.subplots()
121
+ ax.set_xlim(0, 10)
122
+ ax.set_ylim(0, 1)
123
+ x = np.linspace(0, 10, 100)
124
+ y = np.full_like(x, 0.5)
125
+ via_fn = curved_text(ax, x, y, "word", pos=0.5, anchor="center")
126
+ via_cls = CurvedText(x, y, "word", ax, pos=0.5, anchor="center")
127
+ _draw(fig)
128
+ for a, b in zip(via_fn._chars, via_cls._chars):
129
+ assert a.get_position() == pytest.approx(b.get_position())
130
+ plt.close(fig)
131
+
132
+
133
+ def test_set_zorder_lifts_glyphs_above_container():
134
+ fig, ax = plt.subplots()
135
+ x = np.linspace(0, 1, 10)
136
+ ct = curved_text(ax, x, np.zeros_like(x), "ab", pos=0.5, anchor="center")
137
+ ct.set_zorder(5)
138
+ assert ct.get_zorder() == 5
139
+ assert all(t.get_zorder() == 6 for t in ct._chars)
140
+ plt.close(fig)
141
+
142
+
143
+ def test_remove_drops_child_glyphs():
144
+ fig, ax = plt.subplots()
145
+ x = np.linspace(0, 1, 10)
146
+ ct = curved_text(ax, x, np.zeros_like(x), "ab", pos=0.5, anchor="center")
147
+ chars = list(ct._chars)
148
+ ct.remove()
149
+ children = ax.get_children()
150
+ assert ct not in children
151
+ assert all(t not in children for t in chars)
152
+ plt.close(fig)
153
+
154
+
155
+ def test_redraw_is_idempotent():
156
+ # Layout is recomputed every draw; two draws of an unchanged figure must yield
157
+ # the same glyph positions.
158
+ fig, ax = plt.subplots()
159
+ ax.set_xlim(0, 10)
160
+ ax.set_ylim(0, 10)
161
+ x = np.linspace(0, 10, 100)
162
+ ct = curved_text(ax, x, np.sin(x) + 5.0, "stable", pos=0.5, anchor="center",
163
+ offset=8.0)
164
+ _draw(fig)
165
+ first = [t.get_position() for t in ct._chars]
166
+ _draw(fig)
167
+ second = [t.get_position() for t in ct._chars]
168
+ for a, b in zip(first, second):
169
+ assert a == pytest.approx(b)
170
+ plt.close(fig)
171
+
172
+
173
+ def test_validates_inputs():
174
+ fig, ax = plt.subplots()
175
+ with pytest.raises(ValueError):
176
+ CurvedText([0.0], [0.0], "x", ax) # too few points
177
+ with pytest.raises(ValueError):
178
+ CurvedText([0, 1], [0, 1], "x", ax, anchor="middle") # bad anchor
179
+ plt.close(fig)
180
+
181
+
182
+ def test_rejects_non_finite_input():
183
+ fig, ax = plt.subplots()
184
+ with pytest.raises(ValueError):
185
+ CurvedText([0.0, np.nan, 1.0], [0.0, 0.0, 0.0], "x", ax)
186
+ with pytest.raises(ValueError):
187
+ CurvedText([0.0, 1.0], [0.0, np.inf], "x", ax)
188
+ plt.close(fig)