Skip to content

Commit 44dded9

Browse files
Merge branch 'main' into more_nan_fixes
2 parents 6e63064 + 6e901fa commit 44dded9

11 files changed

+214
-80
lines changed

.pre-commit-config.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/asottile/setup-cfg-fmt
3-
rev: v2.7.0
3+
rev: v2.8.0
44
hooks:
55
- id: setup-cfg-fmt
66
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -13,7 +13,7 @@ repos:
1313
hooks:
1414
- id: mypy
1515
- repo: https://github.com/astral-sh/ruff-pre-commit
16-
rev: v0.9.9
16+
rev: v0.11.2
1717
hooks:
1818
- id: ruff
1919
args: ["--fix", "--show-fixes"]

Readme.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,15 @@ lines = ax.get_lines()
5757
l1 = lines[-1]
5858
labelLine(
5959
l1,
60-
0.6,
60+
0.85,
6161
label=r"$Re=${}".format(l1.get_label()),
62-
ha="left",
63-
va="bottom",
6462
align=False,
63+
yoffset=0.01,
64+
ha="right",
6565
backgroundcolor="none",
6666
)
67-
labelLines(lines[:-1], yoffsets=0.01, align=False, backgroundcolor="none")
67+
labelLines(lines[:-1], xvals=0.85, yoffsets=0.01, align=False,
68+
ha="right", backgroundcolor="none")
6869

6970
# labelLines also supports log-scaled x-axes
7071
ax = axes[4]

example.png

640 Bytes
Loading

example/matplotlib_label_lines.ipynb

+82-58
Large diffs are not rendered by default.
201 Bytes
Loading
Loading
13.4 KB
Loading

labellines/core.py

+53-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import warnings
22
from typing import Optional, Union
3-
3+
from datetime import timedelta
44
import matplotlib.pyplot as plt
55
import numpy as np
6+
from datetime import datetime
67
from matplotlib.container import ErrorbarContainer
7-
from matplotlib.dates import DateConverter, num2date
8+
from matplotlib.dates import (
9+
_SwitchableDateConverter,
10+
ConciseDateConverter,
11+
DateConverter,
12+
num2date,
13+
)
814
from matplotlib.lines import Line2D
915
from more_itertools import always_iterable
1016

@@ -19,6 +25,8 @@ def labelLine(
1925
label: Optional[str] = None,
2026
align: Optional[bool] = None,
2127
drop_label: bool = False,
28+
xoffset: float = 0,
29+
xoffset_logspace: bool = False,
2230
yoffset: float = 0,
2331
yoffset_logspace: bool = False,
2432
outline_color: str = "auto",
@@ -43,6 +51,11 @@ def labelLine(
4351
drop_label : bool, optional
4452
If True, the label is consumed by the function so that subsequent
4553
calls to e.g. legend do not use it anymore.
54+
xoffset : double, optional
55+
Space to add to label's x position
56+
xoffset_logspace : bool, optional
57+
If True, then xoffset will be added to the label's x position in
58+
log10 space
4659
yoffset : double, optional
4760
Space to add to label's y position
4861
yoffset_logspace : bool, optional
@@ -65,6 +78,8 @@ def labelLine(
6578
x,
6679
label=label,
6780
align=align,
81+
xoffset=xoffset,
82+
xoffset_logspace=xoffset_logspace,
6883
yoffset=yoffset,
6984
yoffset_logspace=yoffset_logspace,
7085
outline_color=outline_color,
@@ -97,6 +112,7 @@ def labelLines(
97112
xvals: Optional[Union[tuple[float, float], list[float]]] = None,
98113
drop_label: bool = False,
99114
shrink_factor: float = 0.05,
115+
xoffsets: Union[float, list[float]] = 0,
100116
yoffsets: Union[float, list[float]] = 0,
101117
outline_color: str = "auto",
102118
outline_width: float = 5,
@@ -120,6 +136,9 @@ def labelLines(
120136
calls to e.g. legend do not use it anymore.
121137
shrink_factor : double, optional
122138
Relative distance from the edges to place closest labels. Defaults to 0.05.
139+
xoffsets : number or list, optional.
140+
Distance relative to the line when positioning the labels. If given a number,
141+
the same value is used for all lines.
123142
yoffsets : number or list, optional.
124143
Distance relative to the line when positioning the labels. If given a number,
125144
the same value is used for all lines.
@@ -186,18 +205,34 @@ def labelLines(
186205
if isinstance(xvals, tuple) and len(xvals) == 2:
187206
xmin, xmax = xvals
188207
xscale = ax.get_xscale()
208+
209+
# Convert datetime objects to numeric values for linspace/geomspace
210+
x_is_datetime = isinstance(xmin, datetime) or isinstance(xmax, datetime)
211+
if x_is_datetime:
212+
if not isinstance(xmin, datetime) or not isinstance(xmax, datetime):
213+
raise ValueError(
214+
f"Cannot mix datetime and numeric values in xvals: {xvals}"
215+
)
216+
xmin = plt.matplotlib.dates.date2num(xmin)
217+
xmax = plt.matplotlib.dates.date2num(xmax)
218+
189219
if xscale == "log":
190220
xvals = np.geomspace(xmin, xmax, len(all_lines) + 2)[1:-1]
191221
else:
192222
xvals = np.linspace(xmin, xmax, len(all_lines) + 2)[1:-1]
193223

224+
# Convert numeric values back to datetime objects
225+
if x_is_datetime:
226+
xvals = plt.matplotlib.dates.num2date(xvals)
227+
194228
# Build matrix line -> xvalue
195229
ok_matrix = np.zeros((len(all_lines), len(all_lines)), dtype=bool)
196230

197231
for i, line in enumerate(all_lines):
198232
xdata, _ = normalize_xydata(line)
199233
minx, maxx = np.nanmin(xdata), np.nanmax(xdata)
200234
for j, xv in enumerate(xvals): # type: ignore
235+
xv = line.convert_xunits(xv)
201236
ok_matrix[i, j] = minx < xv < maxx
202237

203238
# If some xvals do not fall in their corresponding line,
@@ -213,6 +248,8 @@ def labelLines(
213248
xvals[order] = old_xvals # type: ignore
214249
else:
215250
xvals = list(always_iterable(xvals)) # force the creation of a copy
251+
if len(xvals) == 1:
252+
xvals = [xvals[0]] * len(all_lines)
216253

217254
lab_lines, labels = [], []
218255
# Take only the lines which have labels other than the default ones
@@ -224,6 +261,8 @@ def labelLines(
224261
# Move xlabel if it is outside valid range
225262
xdata, _ = normalize_xydata(line)
226263
xmin, xmax = np.nanmin(xdata), np.nanmax(xdata)
264+
xv = line.convert_xunits(xv)
265+
227266
if not (xmin <= xv <= xmax):
228267
warnings.warn(
229268
(
@@ -243,20 +282,29 @@ def labelLines(
243282
converter = ax.xaxis.converter
244283
else:
245284
converter = ax.xaxis.get_converter()
246-
if isinstance(converter, DateConverter):
285+
time_classes = (_SwitchableDateConverter, DateConverter, ConciseDateConverter)
286+
if isinstance(converter, time_classes):
247287
xvals = [
248288
num2date(x).replace(tzinfo=ax.xaxis.get_units())
249289
for x in xvals # type: ignore
250290
]
251291

252292
txts = []
293+
try:
294+
if isinstance(xoffsets, timedelta):
295+
xoffsets = [xoffsets] * len(all_lines) # type: ignore
296+
else:
297+
xoffsets = [float(xoffsets)] * len(all_lines) # type: ignore
298+
except TypeError:
299+
pass
253300
try:
254301
yoffsets = [float(yoffsets)] * len(all_lines) # type: ignore
255302
except TypeError:
256303
pass
257-
for line, x, yoffset, label in zip(
304+
for line, x, xoffset, yoffset, label in zip(
258305
lab_lines,
259306
xvals, # type: ignore
307+
xoffsets, # type: ignore
260308
yoffsets, # type: ignore
261309
labels,
262310
):
@@ -267,6 +315,7 @@ def labelLines(
267315
label=label,
268316
align=align,
269317
drop_label=drop_label,
318+
xoffset=xoffset,
270319
yoffset=yoffset,
271320
outline_color=outline_color,
272321
outline_width=outline_width,

labellines/line_label.py

+41-6
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from typing import TYPE_CHECKING
44

5+
import matplotlib.dates as mdates
56
import matplotlib.patheffects as patheffects
67
import numpy as np
8+
from datetime import timedelta
79
from matplotlib.text import Text
810

911
from .utils import normalize_xydata
@@ -35,6 +37,12 @@ class LineLabel(Text):
3537
_auto_align: bool
3638
"""Align text with the line (True) or parallel to x axis (False)"""
3739

40+
_xoffset: float
41+
"""An additional x offset for the label"""
42+
43+
_xoffset_logspace: bool
44+
"""Sets whether to treat _xoffset exponentially"""
45+
3846
_yoffset: float
3947
"""An additional y offset for the label"""
4048

@@ -56,6 +64,8 @@ def __init__(
5664
x: Position,
5765
label: Optional[str] = None,
5866
align: Optional[bool] = None,
67+
xoffset: float = 0,
68+
xoffset_logspace: bool = False,
5969
yoffset: float = 0,
6070
yoffset_logspace: bool = False,
6171
outline_color: Optional[Union[AutoLiteral, ColorLike]] = "auto",
@@ -76,6 +86,11 @@ def __init__(
7686
align : bool, optional
7787
If true, the label is parallel to the line, otherwise horizontal,
7888
by default True.
89+
xoffset : float, optional
90+
An additional x offset for the line label, by default 0.
91+
xoffset_logspace : bool, optional
92+
If true xoffset is applied exponentially to appear linear on a log-axis,
93+
by default False.
7994
yoffset : float, optional
8095
An additional y offset for the line label, by default 0.
8196
yoffset_logspace : bool, optional
@@ -108,6 +123,8 @@ def __init__(
108123
self._target_x = x
109124
self._ax = line.axes
110125
self._auto_align = align
126+
self._xoffset = xoffset
127+
self._xoffset_logspace = xoffset_logspace
111128
self._yoffset = yoffset
112129
self._yoffset_logspace = yoffset_logspace
113130
label = label or line.get_label()
@@ -162,19 +179,31 @@ def _update_anchors(self):
162179
x = self._line.convert_xunits(self._target_x)
163180
xdata, ydata = normalize_xydata(self._line)
164181

182+
# Convert timedelta to float if needed
183+
if isinstance(self._xoffset, timedelta):
184+
xoffset = mdates.date2num(self._xoffset + self._target_x) - x
185+
else:
186+
xoffset = self._xoffset
187+
188+
# Handle nan values
165189
mask = np.isfinite(ydata)
166190
if mask.sum() == 0:
167191
raise ValueError(f"The line {self._line} only contains nan!")
168192
xdata = xdata[mask]
169193
ydata = ydata[mask]
170194

171-
# Find the first line segment surrounding x
172-
for i, (xa, xb) in enumerate(zip(xdata[:-1], xdata[1:])):
173-
if min(xa, xb) <= x <= max(xa, xb):
174-
ya, yb = ydata[i], ydata[i + 1]
175-
break
195+
# If the valid data is a single point, then just use that point
196+
if len(xdata) == 1:
197+
xa, xb = xdata[0], xdata[0]
198+
ya, yb = ydata[0], ydata[0]
176199
else:
177-
raise ValueError("x label location is outside data range!")
200+
# Find the first line segment surrounding x
201+
for i, (xa, xb) in enumerate(zip(xdata[:-1], xdata[1:])):
202+
if min(xa, xb) <= x <= max(xa, xb):
203+
ya, yb = ydata[i], ydata[i + 1]
204+
break
205+
else:
206+
raise ValueError("x label location is outside data range!")
178207

179208
# Interpolate y position of label, (interp needs sorted data)
180209
if xa != xb:
@@ -185,6 +214,12 @@ def _update_anchors(self):
185214
else: # Vertical case
186215
y = (ya + yb) / 2
187216

217+
# Apply x offset
218+
if self._xoffset_logspace:
219+
x *= 10**xoffset
220+
else:
221+
x += xoffset
222+
188223
# Apply y offset
189224
if self._yoffset_logspace:
190225
y *= 10**self._yoffset

labellines/test.py

+30-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import datetime, timedelta
22

33
import matplotlib.pyplot as plt
44
import numpy as np
@@ -164,7 +164,20 @@ def test_dateaxis_advanced(setup_mpl):
164164
ax.xaxis.set_major_locator(DayLocator())
165165
ax.xaxis.set_major_formatter(DateFormatter("%Y-%m-%d"))
166166

167-
labelLines(ax.get_lines())
167+
labelLines(ax.get_lines(), xvals=(dates[0], dates[-1]))
168+
return plt.gcf()
169+
170+
171+
@pytest.mark.mpl_image_compare
172+
def test_dateaxis_timedelta_xoffset(setup_mpl):
173+
dates = [datetime(2018, 11, 1), datetime(2018, 11, 2), datetime(2018, 11, 3)]
174+
dt = timedelta(hours=12)
175+
176+
plt.plot(dates, [0, 1, 2], label="apples")
177+
plt.plot(dates, [3, 4, 5], label="banana")
178+
ax = plt.gca()
179+
180+
labelLines(ax.get_lines(), xoffsets=dt)
168181
return plt.gcf()
169182

170183

@@ -315,17 +328,22 @@ def test_label_datetime_plot(setup_mpl):
315328
return plt.gcf()
316329

317330

318-
def test_yoffset(setup_mpl):
331+
def test_xyoffset(setup_mpl):
319332
x = np.linspace(0, 1)
320333

321-
for yoffset in ([-0.5, 0.5], 1, 1.2): # try lists # try int # try float
334+
for offset in ([-0.5, 0.5], 1, 1.2): # try lists # try int # try float
322335
plt.clf()
323336
ax = plt.gca()
324337
ax.plot(x, np.sin(x) * 10, label=r"$\sin x$")
325338
ax.plot(x, np.cos(x) * 10, label=r"$\cos x$")
326339
lines = ax.get_lines()
327340
labelLines(
328-
lines, xvals=(0.2, 0.7), align=False, yoffsets=yoffset, bbox={"alpha": 0}
341+
lines,
342+
xvals=(0.2, 0.7),
343+
xoffsets=offset,
344+
yoffsets=offset,
345+
align=False,
346+
bbox={"alpha": 0},
329347
)
330348

331349

@@ -361,6 +379,13 @@ def test_auto_layout(setup_mpl):
361379
return plt.gcf()
362380

363381

382+
@pytest.mark.mpl_image_compare
383+
def test_single_point_line(setup_mpl):
384+
plt.plot(1, 1, label="x")
385+
labelLines(plt.gca().get_lines())
386+
return plt.gcf()
387+
388+
364389
def test_warning_out_of_range():
365390
X = [0, 1]
366391
Y = [0, 1]

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ test = [
3434
"matplotlib==3.10.1",
3535
"pytest-cov==6.0.0",
3636
"pytest-mpl==0.17.0",
37-
"pytest==8.3.4",
37+
"pytest==8.3.5",
3838
]
3939

4040
[project.urls]

0 commit comments

Comments
 (0)